Verbalization

EQL queries are Python objects — they can be inspected, composed, stored, and now: read aloud.

Verbalization turns any EQL expression into a plain-English sentence. This is useful for:

  • Debugging — instantly understand what a complex query actually asks.

  • Explainability — surface query intent in logs, UIs, or reports.

  • Testing — assert on what a query means (its intent and structure), not just what it returns. Two queries can return the same rows on a fixture yet express different intent (e.g. and_ vs or_); a verbalization assertion pins the intent regardless of the data.

A few grammar words, in plain English

You don’t need any grammar background to read this page. A handful of terms come up now and then — here is what each means in plain words, with the technical name in brackets in case you’re curious:

  • the “X of Y” form“the battery of a Robot” (the possessive / genitive).

  • a “that …” description — extra words hung on a noun to describe it, as in “the Robot that is assigned to a Mission (a relative clause).

  • the subject — the thing a sentence is about, or the thing doing the action (a Robot in “a Robot is operational”).

  • active vs. passive — the doer first (“the Person who owns a Book”) versus the thing it is done to first (“a Book is owned by a Person”).

  • “is” vs. “are” — the verb switches to match one thing versus several (“its salary is …” vs. “their salaries are …”).

  • a “by” relation — a field like owned_by, where the other thing does the action (its agent).

The Quick API

The simplest way to verbalize any EQL expression is verbalize_expression. With no extra arguments it returns plain text. Pass a renderer to control color and layout.

The output reads like a natural sentence describing exactly what the query selects.

More Conditions, Still Readable

Adding more .where() conditions does not break the sentence — EQL connects them naturally.

Cross-Variable Conditions

Verbalization handles cross-variable comparisons too — the sentence describes the relationship between variables.

Logical Operators

Conditions combined with and_, or_, and not_ are verbalized with natural connectives.

Notice the or_ form joins the options with a plain “… or …” — an “or” that allows either one or both (an inclusive or). It deliberately does not start with “either”, which would suggest only one of them can hold. Chained and_ conditions are separated by commas with a final “and”.

Boolean and Indexed Attributes

A bool attribute is read as a short “… is …” statement — “the first task of a Worker is completed — instead of the “X of Y” form (the possessive, e.g. “the battery of a Robot”) used for other attributes. Grammar calls the “is form predicative.

A numeric index like [0] becomes a position word — “first”, “second” (an ordinal) — and merges into the singular noun, so it reads “the first task” rather than “the first of the tasks”. The final yes/no field then reads “is completed” / “is not completed”.

Comparing a yes/no attribute to a yes/no value builds the answer into the verb itself, rather than tacking on “is True”:

== True reads “is completed”, == False (and != True) reads “is not completed”, and a boolean variable whose domain holds both values reads “is either completed or not”.

Relational Attributes

Some attributes are named like a little action — a verb in its “-ed” form plus a small linking word (a past participle plus a preposition), like assigned_to, owned_by, or written_by. Navigating through one of these reads as a “that …” / “which …” description of the related thing (a relative clause), instead of the plain “the assigned_to of …” (the “X of Y” / possessive form). The linking word moves in front of “which” (“to which”, “by which”):

The main noun (“the Robot”) is the attribute’s declared type. A “by” relation (owned_by, written_by) — where the other thing does the action (its agent) — is read with the doer first (the active voice): “the Person who owns a Book”, “the Author who writes a Document”, rather than the thing-it-happens-to first (the passive) “the Person by which a Book is owned”. Every other relation keeps the “… which … is wording. When the owner is the thing the whole query is about, it is referred back to with “it” / “its” the second time (rather than repeating its name): “the battery of the Robot to which it is assigned, “the Person who owns it. A plain noun attribute is untouched and keeps the “X of Y” form (“the name of the department of an Employee”); a noun that merely ends in a preposition (e.g. color_in) is not treated as a relation.

When the robot is the thing the sentence is about (its subject) — as with a yes/no attribute, “the Robot to which it is assigned is operational” — the attributes that follow read “its battery … its power” consistently (once “its” points at the robot, it keeps pointing there):

But when the sentence is about an attribute“the battery of the Robot … is greater than 5” — the battery, not the robot, is what it is about, so a following “its power” would sound like the battery’s power. To stay clear, the owner is spelled out instead:

Absence Conditions (== None)

A comparison to None is read as an absence, not as a value. The exact wording adapts to the attribute:

  • a plain noun attribute reads has no (“a Pose has no orientation”);

  • an action-style attribute (the “-ed”-verb-plus-linking-word kind above, like assigned_to, owned_by, shipped_to) reads as has not been any — the related type is filled in automatically from the attribute’s declared type;

  • a bare variable (nothing to name) reads does not exist”.

Whether an attribute counts as action-style is decided by the shape of the word — the part before the linking word must be a real “-ed” verb form (a past participle) — so assigned_to (a verb) becomes the “has not been assigned to” form, while a noun that merely ends in a small word (e.g. color_in) stays “has no color_in”. Inside a query this absence is said as its own sentence (“such that the Mission has not been assigned to any Robot”) — it never joins the “whose …” group, because that would force the sentence to swap who-does-what, which doesn’t fit there.

Domain-Constrained Values

When a single-value variable (an int/float/str/bool or an enum) is given a small fixed set of allowed values, the verbalizer lists them as “one of …” where the value would go.

Three or more values use a comma before “or” (the serial comma: “one of 10, 50, or 90”); a pair drops it (“one of 10 or 50”). A variable that stands for a whole object (not a single value) is never listed out — it stays “a Robot”.

Concrete Object Values

Comparing against a concrete domain object means its identity, so the verbalizer says “a specific rather than printing the object’s (possibly huge) repr. When the class has an obvious identifying field it is appended:

The identifying field(s) are the dataclass fields marked with GrammarMetadata.is_identifying_field (via field(metadata=FieldMetadata(other_metadata=[GrammarMetadata(is_identifying_field=True)]).as_dict())), otherwise the first present of name / id / label / key / uuid (Robot has name, so this reads “a specific Robot with name ‘R2D2’”). With none of those, it falls back to a bare “a specific Robot”.

Factoring Repeated Comparisons

Two comparisons that check the same fields on two parallel parts of an object — here a period’s begin and end — fold into one natural sentence, “the beginning and end of the period have the same month and year”, instead of repeating each.

A field whose attribute name reads awkwardly can register a display_name in its GrammarMetadata; the verbalizer then uses that word wherever the field appears (here begin surfaces as “beginning”).

Underspecified Constructions (an)

An underspecified an(...) construction asks the system to make something rather than find it — “Generate a … given that …”. Several single-value settings on one object are combined into one “… respectively” line (up to three; longer or phrase-length values are said separately, and a None becomes a “has no” line):

Aggregations

Aggregation functions (count, sum, average, max, min) are wrapped with the definite article and a descriptive phrase when verbalized. Here we need a domain with a numeric field:

All aggregations start with “the” (the definite article): “the number of”, “the sum of”, …. The attribute after it uses the “of the …” (possessive) form.

When a WHERE condition filters the very attribute being aggregated, that attribute is named in full once (in the aggregate) and the condition refers back to it as a bare “the battery”:

Reports (presenting results, not searching)

Some queries present results rather than search for a match — a calculation, or an ordered listing. These open with “Report” rather than “Find”. Report-ness and conditions are orthogonal: a report may still be filtered.

A set_of that computes an aggregate is a calculation, so it opens with “Report” and drops the code-like parentheses:

When it is grouped, the grouping is stated first as a “For each frame (the natural reading of GROUP BY). The key is named once — as the bare group label — and is not restated as a column:

An ordered query is also a report — ordering shows all the matching results in sequence, a listing rather than a hunt for one. So the subject is plural, and a filter doesn’t change that (it just shortens the list). When there is more than one, the wording matches it: the verb becomes “are” (“whose salaries are …”) and a per-item value spreads to each (“their salaries”):

The same matching applies wherever the subject is plural — including a limit that ranks several (“the top three Employees … whose salaries are greater than 1000”).

A limit is the exception: it ranks and counts (“Find the top three …”).

A grouped query’s having filter is attached to the group name as a “whose is …” condition (the total read as a plain possession of the group), so it is clear which group the condition filters:

A plain (non-aggregating, unordered) set_of stays a search and also drops the parentheses. Several attributes of the same owner fold into one shared “X of Y” phrase rather than repeating the owner:

Date Range Folding

When a lower-bound and an upper-bound comparison on the same datetime attribute appear together, the verbalizer folds them into a single “between … and …” phrase.

The output uses “is between … and …” with the datetime values formatted in a human-readable form. The same folding happens when the comparisons appear on an aggregation sub-query’s WHERE clause (see the next section).

Nested Sub-Queries and Aggregation Scoping

Aggregation sub-queries nest naturally. A scoped aggregation — “the sum of amounts among BankTransactions whose booking_date is between …” — is produced when an aggregate appears inside an entity() wrapper with its own WHERE conditions.

The word “its” stands in for a repeated “of the BankTransaction” on the outer condition. The scoped aggregation automatically uses “among” and the WHERE conditions inside the sub-query continue to work (date-range folding, pronouns, etc.).

A maximum-value variant produces a similarly compact form:

And a scoped aggregation can stand alone as the main query — no outer entity needed:

Same-Type Variable Disambiguation

When two variables of the same type appear in a query, the verbalizer distinguishes them by appending a numeric index — “Employee 1”, “Employee 2”.

The same mechanism also handles disambiguation when an aggregate and an entity share a type:

It also counts the related entity a relational attribute introduces, so two missions assigned to two different robots read “Robot 1” / “Robot 2” rather than two indistinguishable *”the Robot”*s — and a repeat of either reduces to its numbered label:

Custom Predicates

A custom predicate can control its verbalization by implementing _verbalization_template_. The template is a string with {field_name} placeholders corresponding to the predicate’s dataclass fields.

Predicates with multiple fields receive their arguments in positional order:

When a predicate does not define _verbalization_template_, the verbalizer falls back to a generic description.

Grouped Queries (set_of + grouped_by + having)

Queries built with set_of, grouped_by, and having are verbalized as a structured sentence with the selection, GROUP BY, and HAVING clauses clearly separated.

The HAVING clause is attached to the group name as “For each department whose is greater than 30000” — putting the filter on the group it restricts, rather than a trailing “having …” that would look like it describes the whole list of results. The GROUP BY part names only the grouping key, without repeating the full set of selected columns.

Colored Terminal Output

For richer output in a terminal, use VerbalizationPipeline with a renderer (verbalize_expression itself only returns plain text). Each part of the sentence is color-coded by what kind of thing it is (its role).

Color legend:

Color

Role

Example

Cornflower blue

Variable type

Robot, Mission

Teal

Attribute

battery, assigned_to

Orange

Operator

is greater than, is not

Green

Logical connective

and, or, such that

Gray

Literal value

50, "BB8"

Yellow

Keyword / rule structure

If, then, whose

HTML Output for Notebooks

Build a VerbalizationPipeline with an HTMLFormatter renderer to produce <span> tags for direct use in Jupyter or any HTML context.

Hierarchical HTML

Use HierarchicalRenderer to get an indented bullet structure — great for rule trees.

Verbalizing Rule Trees

Verbalization really shines on rule trees. The if/then structure is rendered clearly.

The hierarchical renderer shows the If/then structure with each condition and conclusion on its own line, indented under the relevant part.

Deep Nesting in Hierarchical Mode

The hierarchical view really shines on rules with deeply nested attribute chains — the bullet structure makes the relationship between conditions visually clear. Here is a drawer-detection rule with a multi-hop path:

And a cabinet rule that aggregates over multiple drawers — notice the “if” part (the condition) uses “there are” and the “then” part uses the plural “are” to match the several drawers:

Verbalization as an Explanation Tool

Because EQL tracks how inferences were made, you can verbalize the query that produced any inferred result — not just hand-written queries.

This outputs the exact query that matched and produced inferred_object, described in English. It is directly useful for displaying why a robot perceives something as a Drawer, a Door, etc.

API Reference

  • verbalize_expression() — the simplest entry point; returns plain text (verbalize_expression(expr))

  • VerbalizationPipeline — colour / layout / hyperlinks via a renderer

    • VerbalizationPipeline.plain().verbalize(expression) — plain prose (what verbalize_expression uses)

    • VerbalizationPipeline.ansi().verbalize(expression) — ANSI-coloured prose

    • VerbalizationPipeline.html(hierarchical=True).verbalize(expression) — HTML indented bullets

    • VerbalizationPipeline.html(hierarchical=True, link_resolver=resolver).verbalize(expression) — with source hyperlinks

    • VerbalizationPipeline(renderer).verbalize(expression) — from a renderer you build directly

  • ParagraphRenderer — flat prose layout

  • HierarchicalRenderer — indented bullet list layout

  • PlainFormatter — no colour markup

  • ANSIFormatter — terminal escape codes

  • HTMLFormatter<span> tags for notebooks