Verbalization Internals#

This guide explains the architecture of the EQL verbalization subsystem for developers who want to understand, extend, or debug it. End-user documentation lives in Verbalization.

Overview#

The verbalization subsystem translates any EQL symbolic expression into a human-readable English string (or a structured fragment tree that can be rendered in plain text, ANSI colour, or HTML).

The class and attribute names that appear in the generated text keep a reference to where they came from, so the ANSI and HTML renderers can turn them into hyperlinks back to the API documentation — clicking a name in a verbalization jumps to that class’s or attribute’s Sphinx AutoAPI page. This means the generated English is not just prose but a navigable index into the API docs (see SourceLinkResolver).

The single entry point is VerbalizationPipeline, with verbalize_expression() as the plain-text shortcut:

# Simplest — plain text, no colour (== VerbalizationPipeline.plain().verbalize(...))
from krrood.entity_query_language.verbalization.pipeline import verbalize_expression
text = verbalize_expression(query)

# Colour / layout / links — choose a pipeline factory
from krrood.entity_query_language.verbalization.pipeline import VerbalizationPipeline

VerbalizationPipeline.plain().verbalize(query)                    # plain prose
VerbalizationPipeline.ansi().verbalize(query)                     # ANSI true-colour prose
VerbalizationPipeline.ansi(hierarchical=True).verbalize(query)    # ANSI, indented bullets
VerbalizationPipeline.html(link_resolver=resolver).verbalize(query)  # HTML <span> + links

# Full control — construct a pipeline from a renderer + formatter directly
from krrood.entity_query_language.verbalization.rendering.formatter import HTMLFormatter
from krrood.entity_query_language.verbalization.rendering.renderer import HierarchicalRenderer
pipeline = VerbalizationPipeline(HierarchicalRenderer(HTMLFormatter(), resolver))
html = pipeline.verbalize(query)

Pass a shared MicroplanningServices to verbalize across calls to get cross-mention coreference (“a Robot”“the Robot”). A construct with no grammar rule raises UnverbalizableExpressionError — coverage gaps fail loudly rather than degrading to a bare class name.

Glossary#

The subsystem borrows standard terms from functional programming and from the natural-language generation (NLG) literature. They are defined once here.

catamorphism#

A fold that collapses a recursive structure to a single value by replacing each node with a handler. The verbalizer folds the EQL tree into a fragment tree, and the renderer folds the fragment tree into a string.

F-algebra#

The bundle of per-node handlers a catamorphism applies — the grammar rules over EQL nodes, or the four word / role / phrase / block handlers over the fragment tree.

homomorphism (Montague)#

The structure-preserving map from the syntax algebra (EQL constructs) to the semantics algebra (English phrases): one rule per construct, which is what PhraseRule realises.

microplanning#

The NLG stage that decides content and structure — what to say and in what shape — before surface realisation. Here, the planners.

surface realisation#

The NLG stage that turns a planned structure into grammatical text (agreement, determiners, inflection, spacing). Here, the assemblers plus the ordered realisation passes.

coreference#

Referring to an already-introduced entity with a reduced form (“a Robot”“the Robot” / “its …”).

referring expression#

The noun phrase chosen to identify an entity — indefinite, definite, numbered, or pronoun.

morphology#

Word-form inflection: pluralisation and verb agreement.

orthography#

Surface spelling concerns: punctuation spacing and capitalisation cleanup.

coordination (aggregation)#

Combining several conditions into one coordinated phrase — Oxford-comma lists, “between … and …” ranges, or “… have the same …” co-indexed comparisons.


Architecture: build → realise → render#

        graph LR
    A[EQL Expression] --> B[fold + realisation passes]
    B -- realised VerbalizationFragment tree --> C[FragmentRenderer]
    C -- formatted string --> D[Output]
    E[grammar RULES / select] -. dispatch .-> B
    F[MicroplanningServices] -. services .-> B
    G[Formatter] -. markup .-> C
    H[SourceLinkResolver] -. URLs .-> C
    

Layer 1 — VerbalizationFragment building + realisation (EQLVerbalizer.build)#

EQLVerbalizer is the internal fragment builder behind the pipeline (use it directly only when you want the fragment tree itself, e.g. in tests). build(expression, services):

  1. Folds the EQL tree into a VerbalizationFragment tree via the grammar (see Rule dispatch).

  2. Runs the ordered realisation passes (realize_tree()): coreference → determiner → morphology → orthography (see Realisation passes).

It never produces strings — formatting is Layer 2/3.

Layer 2 — VerbalizationFragment rendering (FragmentRenderer)#

FragmentRenderer traverses the realised tree and produces a single string.

Renderer

Output style

ParagraphRenderer

Flat prose; BlockFragments joined inline

HierarchicalRenderer

Indented bullet lists; each BlockFragment nesting level adds one indent

Layer 3 — Format markup (Formatter)#

Formatter injects format-specific characters into the renderer output.

Formatter

Colour encoding

Space

Newline

Links

PlainFormatter

none

" "

"\n"

no

ANSIFormatter

\033[38;2;R;G;Bm

" "

"\n"

OSC 8

HTMLFormatter

<span style="color:…">

&nbsp;

<br>

<a href>


VerbalizationFragment Type Hierarchy#

All verbalization output is expressed as a tree of VerbalizationFragment subclasses. There are leaf nodes (text), structural containers (hold children), and one coreference marker (PossessiveChain) the realisation passes consume and strip.

        classDiagram
    class VerbalizationFragment {
        <<abstract>>
    }
    class WordFragment {
        role-less text: articles, punctuation, connectives
    }
    class RoleFragment {
        text + SemanticRole + optional SourceReference (for hyperlinking)
    }
    class PhraseFragment {
        inline sequence of children joined by a separator
    }
    class NounPhrase {
        head + Definiteness + Number + modifiers + referent_id (a DP spec, lowered later)
    }
    class BlockFragment {
        header + item fragments (flattens or indents on render)
    }
    class PossessiveChain {
        a chain whose its/of form coreference decides (stripped after coreference)
    }
    VerbalizationFragment <|-- WordFragment
    VerbalizationFragment <|-- RoleFragment
    VerbalizationFragment <|-- PhraseFragment
    VerbalizationFragment <|-- NounPhrase
    VerbalizationFragment <|-- BlockFragment
    VerbalizationFragment <|-- PossessiveChain
    

NounPhrase is a spec, not a lowered phrase: rules emit it with grammatical features (Definiteness, Number) but no determiner; the determiner pass lowers it to a PhraseFragment. The recursion helpers over this tree are fold_fragment() (a catamorphism to any value — used by the renderer/flatten), map_structural_children() / map_fragment() (structure-preserving rebuilds — used by the realisation passes), and flatten_fragment_to_plain_text().

SemanticRole and Colours#

SemanticRole is a presentation classification (decoupled from the EQL type taxonomy) that determines the colour applied by formatters, and is read by the morphology pass for copula agreement (OPERATOR). Colours match the QueryGraph.ColorLegend palette.

Role

Example

Colour

KEYWORD

Find, If, such that

yellow #eded18

VARIABLE

Robot, Employee 1

cornflower blue

AGGREGATION

sum of, number of

red-orange #F54927

OPERATOR

is greater than, is

orange #ff7f0e

LOGICAL

and, or, not, for all

green #2ca02c

LITERAL

42, "hello"

gray #949292

ATTRIBUTE

battery, tasks

teal #8FC7B8

PLAIN

of, the, , (WordFragment, role-less)

none

Building Fragments#

Construct fragments directly (there are no factory helpers), and prefer the vocabulary constants for any fixed word so no English string is ever inlined:

from krrood.entity_query_language.verbalization.fragments.base import (
    WordFragment, RoleFragment, PhraseFragment, NounPhrase, BlockFragment, oxford_comma,
)
from krrood.entity_query_language.verbalization.vocabulary.english import (
    Articles, Conjunctions, Punctuation,
)

# Fixed words / punctuation → vocabulary constants (avoids typos, carries the role)
the   = Articles.THE.as_fragment()
comma = Punctuation.COMMA.as_fragment()

# Coloured word with a source reference (for hyperlinking variables / attributes)
role_frag = RoleFragment.for_variable("Robot", robot_var)   # VARIABLE role + source link
attr_frag = RoleFragment.for_attribute(Robot, "battery")    # ATTRIBUTE role + link
op_frag   = RoleFragment.for_operator("is greater than")    # OPERATOR role, no link

# Inline sequence (default " " separator)
phrase = PhraseFragment(parts=[the, role_frag, op_frag])

# Oxford-comma join (serial comma for 3+; no comma for a pair — pass pair_comma=True to keep it)
list_frag = oxford_comma([frag_a, frag_b, frag_c], Conjunctions.AND.as_fragment())

# Noun-phrase spec (the determiner is added by the determiner pass, not here)
np = NounPhrase(head=RoleFragment.for_variable("Robot", robot_var), referent_id=robot_var._id_)

# Block structure (renders as bullets in HierarchicalRenderer)
block = BlockFragment(header=keyword_frag, items=[item1, item2])

Rule dispatch (the fold)#

fold() is the single place the EQL tree is recursed — an F-algebra catamorphism. For each node it:

  1. Checks context.scope.binding_overrides for the node’s _id_ — returns the override immediately if present (used for InstantiatedVariable field references).

  2. select()s the most-specific PhraseRule and calls its build, handing it a fresh RuleContext whose child re-enters fold.

  3. If no rule matches, raises UnverbalizableExpressionError.

A rule never recurses by hand — it calls context.child(sub_expression). child accepts optional per-recursion render flags (number, inline, as_value), bundled internally as a RenderOptions; a single root_context factory builds the RuleContext so the continuation is defined in one place (a new flag is one field on RenderOptions, not a signature change at every call site).

Query scoping is declarative. A rule whose construct is a query body (the top-level / nested Entity and SetOf rules) declares enters_query_scope = True; the engine then runs its build inside context.configuration.query_depth_scope(), so any Entity found within renders as a nested noun phrase (query_depth > 0) rather than emitting “Find …”. Neither rules nor assemblers ever push the scope by hand. when runs outside the scope — it guards on the rule’s own position (query_depth == 0 for the top-level form).

Specificity#

select ranks the rules whose construct matches (isinstance) and whose when guard passes, by the key (construct MRO depth, guarded over unguarded, rule-class MRO depth), highest wins. Specificity comes primarily from the construct class, so rules stay flat; the final component lets a rule that is a genuine special case of another express that by subclassing it (when both guards hold, the more-derived rule class wins). For example Literal <: Variable, so LiteralRule (deeper construct) shadows VariableRule; a guarded boolean-attribute chain rule outranks the plain possessive chain rule.

The same most-specific-wins selection is reused, off the EQL tree, by the SpecificityRule registries — several small families of guarded alternatives ranked by class depth: the ConditionForm surface-form registry, the PredicateTransform predicate registry, the RankingForm selection-ranking registry, and the RestrictionSubjectRule registry. Adding an alternative is a new subclass; nothing else changes.

How to Extend — A Worked Example#

This walkthrough adds verbalization for a hypothetical Between operator (between(x, lo, hi)) rendering as “x is between lo and hi”.

Step 1 — Where does the code go?#

The grammar is organised per construct under grammar/<construct>/ (terms, chain, conditions, query, inference, aggregation, clauses, instantiated, match). Each folder has a rules.py; the involved constructs also have a planner.py + assembler.py.

  • A simple construct (one phrase, no content decisions) → add one PhraseRule subclass to the relevant construct’s rules.py (a comparator-like operator belongs in grammar.conditions.rules). That’s the whole change.

  • A complex construct (needs what to say analysis separate from how to say it) → add a Planner + Assembler pair in the construct’s folder (Planner / Assembler bases) and have the rule’s build delegate to the assembler.

Between is simple, so it’s a single rule in grammar/conditions/rules.py.

Step 2 — Write the rule#

Set construct, optionally name/when (and enters_query_scope = True if the construct is itself a query body), and implement build(node, context). Start the docstring with the target string in the established convention (“x is between lo and hi”) — that line is how readers get the output intuition at a glance, and the rule-doctest harness (test_rule_doctests) executes it:

class BetweenRule(PhraseRule):
    """Verbalizes a Between operator as *"x is between lo and hi"*.

    >>> verbalize_expression(between(x, lo, hi))
    'x is between lo and hi'
    """

    construct = Between
    name = "between"

    def build(self, node, context: RuleContext) -> VerbalizationFragment:
        return PhraseFragment(
            parts=[
                context.child(node.left),                   # recurse via the fold
                RangePhrases.IS_BETWEEN.as_fragment(),       # fixed phrase from the lexicon
                oxford_comma(
                    [context.child(node.lo), context.child(node.hi)],
                    Conjunctions.AND.as_fragment(),
                ),
            ]
        )

Step 3 — Registration is automatic#

RULES is auto-discovered: RULES instantiates every concrete PhraseRule subclass via concrete_subclasses(PhraseRule) (abstract bases filtered out) — the same subclass-discovery primitive the SpecificityRule families use. The registry first walks the grammar package and imports every grammar/<construct>/rules.py, so the subclasses exist before RULES is built; adding a construct’s rules.py is enough — there is no import list to edit. (select is specificity-based, so definition order is irrelevant.)

Step 4 — Recurse with context.child, decide with context services#

context.child(sub_expression) re-enters the fold, so sub-expressions get coreference, binding overrides, and pronoun resolution for free. Reach cross-cutting state only through the RuleContext services — context.refer (referring expressions), context.scope (binding), context.configuration (render flags), context.microplan (the plan read model) — never reach into the service internals directly. Never call verbalize_expression(child) from a rule: that starts a fresh services bundle and breaks coreference.

Step 5 — Use constructors + vocabulary constants#

Build fragments with the constructors (WordFragment/RoleFragment/PhraseFragment/NounPhrase), and source every fixed word from the lexicon (Keywords, Copulas, Operators, Punctuation, …) via .as_fragment(). No English string — including punctuation — belongs in a rule.

Enum

Example

.as_fragment() role

Keywords

Keywords.FIND

KEYWORD

Copulas

Copulas.IS / Copulas.for_number(n)

OPERATOR

Operators

Operators.from_callable(op.le).select(...)

OPERATOR

Logicals

Logicals.FOR_ALL

LOGICAL

Aggregations

Aggregations.COUNT

AGGREGATION

Articles

Articles.THE

PLAIN

Conjunctions

Conjunctions.AND

PLAIN

Prepositions

Prepositions.OF

PLAIN

Punctuation

Punctuation.COMMA

PLAIN

Pronouns

Pronouns.ITS

PLAIN

For coloured / linked fragments referencing a Python class or attribute, use the RoleFragment class methods:

RoleFragment.for_variable("Robot", var)       # VARIABLE role + SourceReference to type
RoleFragment.for_attribute(Robot, "battery")  # ATTRIBUTE role + SourceReference to attr
RoleFragment.for_operator("is greater than")  # OPERATOR role, no link

Step 6 — Write a test#

Add a golden-string case to test/krrood_test/test_eql/test_verbalization/test_verbalization.py:

def test_verbalize_between():
    assert verbalize_expression(between(x, lo, hi)) == "x is between lo and hi"
pytest test/krrood_test/test_eql/test_verbalization -x

The planner / assembler split (complex constructs)#

Queries, inference rules, instantiated variables, matches, chains, and aggregations are too involved for a one-method rule, so they split what to say from how to say it:

  • a Planner performs pure structural analysis into a frozen plan (no fragments, no context mutation, no recursion). Plans are computed once and shared via the Microplan read model (context.microplan.plan_for(node, SomePlanner));

  • an Assembler realises that plan into fragments (it owns recursion and any render-scope mutations).

The rule’s build just calls XAssembler(context).assemble(node) (or, for plan-less constructs, a dedicated assembler method). The orchestrating QueryAssembler delegates cohesive sub-forms to their own components: the trailing clauses to clauses (assembler + composer), the WHERE partition to the ConditionForm registry via as_subject_restrictions(), and the aggregation value-subquery to AggregationValueAssembler.

A query searches (Find) or presents (Report) by one plan-level shape, not a render-time isinstance: the QueryPlanner sets a ReportPlan (with a ReportKind) when the query presents results rather than hunting for a match. Report-ness and conditions are orthogonal axes — Find/Report answers “hunt or present?”, the WHERE answers “over which subset?” — so a report may still be filtered. Two kinds present results: AGGREGATION (a set_of computing an aggregate) and ORDERING (any ordered_by listing, since ordering ranges over the whole result set). The opening verb (QueryAssembler._verb) and the subject’s number both derive from the plan, so the same body builders serve both modes: a search reads as natural prose “Find a and b” (Oxford-comma, no code-like parentheses); an AGGREGATION report opens with “Report …” and, when grouped, fronts its grouping as “For each , report …” (the result’s row dimension stated first — reads as the GROUP BY idiom and drops the redundant key column and trailing “grouped by” clause, keys rendered as the bare group label); an ORDERING report opens with “Report …” and renders the subject plural (“Report Employees ordered by their salary” — a plural subject pronominalises to “their” via the same Number plumbing the ranking surface uses). A ranked query (limit) is excluded from the report shape and keeps the “Find the top three …” ranking form, whose count-bearing pre-head is a distinct surface. Within a selection list, contiguous plain attributes sharing one owner fold into a coordinated genitive (coordinated_genitive: “the department and salary of an Employee”, not “… and its salary”) — the assembler groups by the attributes’ shared _child_ owner and renders the owner once. “report” is held lowercase in the vocabulary and capitalised by the assembler only when it opens the sentence (there is no global sentence-case pass — leading capitals are positional in the keywords).

Navigation chains analyse the chain once into a ChainPlan (ChainPlanner); ChainAssembler then renders the plural / boolean-predicative / possessive form, with the possessive/pronominal surface built by possessive over build_path_parts(). The boolean predicative is does not re-render its navigation prefix: the prefix is the chain minus its boolean terminal (terminal._child_), recursed through the standard grammar, so it reuses the very possessive / relational / pronominal surfaces above — and pronominalises to the discourse subject (“the Robot to which it is assigned is operational”) for free.

A hop whose attribute is a relation (a past participle + preposition, e.g. assigned_to) renders as a relative clause — “the <Type> <prep> which <owner> is <participle> (“the Robot to which a Mission is assigned”) — instead of the genitive “the <attr> of <owner>. The head is the hop’s _type_; the verb is split by relational_verb() into participle + preposition (one recognizer, with relational_verb_phrase — the stranded “assigned to” absence uses — a thin wrapper over it), so the preposition pied-pipes before which. Detection is injected into build_path_parts (the detector is passed by ChainPlanner, so navigation_path stays free of any grammar import), and the relational flag rides on the PathStep (relation) so every navigation surface — possessive, boolean prefix, deferred chain, and pronominal — renders it uniformly through the shared possessive/pronominal hop builders. Keeping the owner the verb’s subject means the meaning never flips for agentive relations (“the Person by which a Book is owned”); when the owner is the discourse subject it pronominalises to the nominative it / they, the copula agreeing (”… to which it is assigned” / “… to which they are assigned”). The relative clause is itself a referring NounPhrase keyed on the navigated value node’s id (repeated source-level navigations share that node, so they share the id): the first mention spells the clause out, and a repeat of the same navigation reduces to a bare “the Robot” through the ordinary coreference downgrade — so “the battery of the Robot to which it is assigned … and the power of the Robot says the clause once. This is why the coreference pass walks the built chain (rather than returning it raw): the freshly-emitted relative-clause noun phrases must pass through first/repeat resolution. An attribute reached through the local centre — the relational referent the immediately preceding chain was about (the owner of its outermost attribute) — goes one step further and pronominalises: “the power of the Robot to which it is assigned” becomes “its power” (_relational_possessive, mirroring the subject’s “its …” but anchored on the relational referent rather than the chain root). The centre is the one piece of running state the pass keeps besides _seen: each chain sets it to its own topic after resolving, and a chain about something else clears it — so “its” binds only to the referent named directly before, and stays unambiguous even among numbered referents (“Robot 1 … its power … Robot 2”; but “Robot 1 … Robot 2 … the power of Robot 1”, never a stranded “its” reaching across the intervening Robot 2). This is centering theory (Grosz, Joshi & Weinstein 1995) in its smallest useful form. Two distinct relational referents of one type (two missions’ robots) would both reduce to an ambiguous “the Robot”, so they are numbered “Robot 1” / “Robot 2” — the same ReferringExpressions disambiguation map that numbers colliding variables, now also counting relational referents (keyed on the value node id). A relational referent has no rule to label it (its clause is built in the microplanner), so the number is stamped on by the coreference pass from numbered_labels, and a numbered (BARE) clause reduces to its bare label on repeat.

Conditions are owned by ConditionAssembler (the surface forms) plus the ConditionForm registry (which form attaches where). Its predicate form does not hard-code the special cases either: it dispatches over the PredicateTransform registry, so the generic is one transform alongside the absence and boolean-polarity (“is [not] ) forms. Pure structural predicates live in recognition and the operator phrase in comparator_operator().

render_absence (a == None comparison) picks its wording by the attribute’s part of speech: a plain noun reads has no , while a relational attribute — a past participle plus a preposition (assigned_to, owned_by) recognised by relational_verb_phrase() — reads as a passive verb naming the attribute’s declared type, has not been assigned to any . The participle test is the deterministic, offline is_past_participle() (a lemminflect dictionary+rule lookup, not a statistical POS tagger), so it is reproducible and needs no model download; nouns that merely end in a preposition (color_in) are correctly left as “has no …”.


Realisation passes#

After the fold, realize_tree() runs four ordered passes over the fragment tree:

  1. CoreferenceProcessor — resolves referring expressions in document order and strips PossessiveChain markers (below).

  2. DeterminerProcessor — lowers every NounPhrase to a determiner-bearing PhraseFragment via the concord table (INDEFINITE×SINGULAR → a/an; INDEFINITE×PLURAL → bare; DEFINITE → the; UNIQUE → the unique; BARE → no determiner), and tags the head with its Number.

  3. MorphologyProcessor — inflects every leaf tagged Number.PLURAL (pluralise nouns; copula suppletion isare) via the morphology facade over inflect / lemminflect.

  4. OrthographyProcessor — removes the space adjacent to glued punctuation (a Punctuation token carries a spacing feature), so rules emit “,” / “(” / “)” as ordinary, normally-separated tokens and still get “x, y” / “(x)”.

Coreference#

Rules emit the first-mention form — a NounPhrase tagged with a referent_id (and Definiteness INDEFINITE / BARE, or UNIQUE for a “the unique X” the(entity(...)) selection), with variable-rooted chains emitted as a PossessiveChain. The discourse focus (which referent is the current subject, for “its …”) is not marked by rules; it is projected once from the shared plan read model by DiscourseModel (DiscourseModel.from_expression(expression, microplan)), and the coreference pass consults it. The pass then, in output order:

  • keeps the first mention of a referent (e.g. “a Robot”) and marks it introduced;

  • downgrades a repeat singular mention to definite, dropping the first-mention modifiers (“the Robot”; a UNIQUE “the unique Robot” likewise downgrades to a plain DEFINITE “the Robot”); numbered labels (“Robot 2”, BARE) never downgrade;

  • renders a PossessiveChain as “its …” when its root is the discourse subject, else as the possessive “the … of …”;

  • reduces a repeat of a query’s selected quantity — an aggregation’s measured attribute, which DiscourseModel also collects (is_selected_quantity) — to a bare “the battery”, so a WHERE on the very attribute being aggregated does not repeat the whole possessive (“the average of the battery of the Robot to which a Mission is assigned such that the battery is greater than 5”). The PossessiveChain carries its own node_id for this match; the first mention (in the aggregate) spells out in full, the repeat reduces.

The local centre (_chain_topic) is the grammatical subject of the clause just said — the antecedent a reader binds “its” to (centering theory: the backward-looking centre is the highest-ranked entity, subject before oblique). A relational referent is that subject only when nothing is predicated of an attribute of it: a boolean predicative (“the Robot to which it is assigned is operational”) or a bare relational mention — there the referent heads the subject phrase, so a following attribute reads “its battery”. But a genitive attribute (“the battery of the Robot … is greater than 5”) is itself the subject head; the Robot is an oblique inside it. So a following “its power” would bind to the battery (“battery power”!), not the Robot — wrong — and the owner is spelled out instead: “the power of the Robot”. This is one rule, not a special case: an aggregation’s measured quantity (“the average of the battery …”) is likewise headed by the attribute, so it clears the centre the same way. One refinement keeps a run consistent: a clause that is itself an “its …” continuation of the current centre keeps it (a centering CONTINUE — the pronoun signals the topic persists), so a string of attributes on one referent reads uniformly “its battery … its power” rather than mixing “its battery … the power of the Robot”. The asymmetry that protects the rule above survives: “its” can only start after the referent was a clause subject, but once started it continues.

The build is therefore free of in-fold coreference mutation. ReferringExpressions holds only the pre-computed disambiguation map and a seen set of introduced referent ids — the latter solely to seed the pass across builds sharing one services bundle. numbered_label(variable) is the single source of the disambiguation lookup; noun_for_parts(variable) builds on it for the first-mention Definiteness.

Disambiguation map#

Built by MicroplanningServices.from_expression(expression), which pre-scans the tree. Types with a single variable keep the plain type name; collisions get numbered labels:

Robot    (single)  →  "Robot"
Apple 1  (first)   →  "Apple 1"
Apple 2  (second)  →  "Apple 2"

Microplanning services#

A single MicroplanningServices threads four single-responsibility services through one build; rules reach them via the RuleContext properties.

Service (RuleContext accessor)

Responsibility

ReferringExpressions (context.refer)

disambiguation map + introduced-referent seen set

BindingScope (context.scope)

deferred-constraint frames + field-reference overrides

RenderConfiguration (context.configuration)

render-mode flags (query depth, compact predicates)

Microplan (context.microplan)

the plan read model — each node’s plan computed once and shared (lazy / memoised)

Value lexicalisation (Python value → text — e.g. None“nothing”, an enum member → its name, a datetime“May 23, 2026”) is the pure module-level function value_phrase(), not a method on the services.

Constraint frames (BindingScope)#

Used by the InstantiatedVariable path: when an Entity is a chain root inside an InstantiatedVariable, its WHERE condition is deferred into a frame rather than verbalized inline. After all binding overrides are registered, the deferred expressions are emitted as a “such that …” clause.

context.scope.push_constraint_frame()
context.scope.defer_constraint(expression)
deferred = context.scope.pop_constraint_frame()

Binding overrides (BindingScope)#

context.scope.binding_overrides maps expression._id_VerbalizationFragment. fold checks it before dispatch, so when a bound variable appears again as a WHERE value it reuses the “the field of the Type” fragment instead of re-verbalizing the raw variable.


Coordination (aggregation / conjunction reduction)#

coordination owns the EQL-level conjunction-reduction microplanning task as a single pass. ConjunctReducer is that pass: it applies an ordered registry of ConjunctFold strategies to a flat conjunct list — adding a fold is a new strategy, nothing else changes (open/closed). The default registry is RangeBoundFold (complementary lower/upper bound comparisons on one chain → a RangeFold, rendered “… is between lo and hi” by build_between()) then CoindexedComparisonFold (co-indexed comparisons across two prefixes → a CoindexedFold, “the begin and end … have the same month and year”). reduce_conjuncts() is the thin function entry every conjunct-rendering caller uses (it constructs the default reducer), so no caller has to know a fold exists. The fold artifacts are first-class verbalizable nodes with their own PhraseRules, so a reduced list renders through the normal recursion. VerbalizationFragment-level Oxford-comma joining is oxford_comma().



Module map#

Area

Modules

Engine

engine.py (fold), verbalizer.py (EQLVerbalizer), pipeline.py (VerbalizationPipeline, verbalize_expression), context.py (MicroplanningServices), exceptions.py (UnverbalizableExpressionError)

VerbalizationFragment IR

fragments/base.py (the VerbalizationFragment hierarchy + fold_fragment / map_structural_children / map_fragment / oxford_comma / flatten_fragment_to_plain_text), fragments/features.py, fragments/roles.py, fragments/source_reference.py

Lexicon

vocabulary/english.py, vocabulary/words.pyall English words/phrases/punctuation

Grammar framework

grammar/framework/phrase_rule (PhraseRule, RuleContext, select), registry (RULES, auto-discovered by walking the grammar package), specificity (SpecificityRule, most_applicable, and the shared concrete_subclasses / most_specific / mro_depth selection primitives), assembler (Assembler base), planner (Planner base)

Grammar (per construct)

grammar/<construct>/terms, chain, conditions, query, inference, aggregation, clauses, instantiated, match; each has rules.py, and the involved ones a planner.py + assembler.py

Conditions

grammar/conditions/rules (entry PhraseRules), assembler (ConditionAssembler surface-form toolkit), predication (PredicateTransform registry + operator-word selection comparator_operator / coindexed_operator + render_absence), placement (ConditionForm registry, Slot, place / as_subject_restrictions), subject (RestrictionSubjectRule, restriction_subject), recognition (structural recognizers)

Microplanning

microplanning/referring, binding_scope, config (RenderConfiguration), microplan (Microplan), coordination, possessive

Rendering

rendering/realization (the passes), coreference_processor, determiner_processor, morphology_processor, orthography_processor, discourse (DiscourseModel), renderer, formatter, source_link_resolver

Utilities

morphology.py (word-morphology facade over inflect + lemminflect: pluralise / article / ordinal / is_past_participle), navigation_path.py (build_path_parts, PathStep), relational_attributes.py (the relational-attribute recognizer relational_verb shared by navigation and absence), value_lexicon.py (value_phrase); chain walking is core.expression_structure.walk_chain; aggregation-subquery structure is query.aggregation_structure


How to Add a New Output Format#

Subclass Formatter (and optionally a renderer):

from dataclasses import dataclass
from krrood.entity_query_language.verbalization.rendering.formatter import Formatter
from krrood.entity_query_language.verbalization.fragments.roles import SemanticRole

@dataclass
class MarkdownFormatter(Formatter):
    """Renders colour as Markdown bold (no true colour in plain Markdown)."""

    def colorize(self, text: str, role: SemanticRole) -> str:
        if role in (SemanticRole.KEYWORD, SemanticRole.VARIABLE):
            return f"**{text}**"
        return text

    @property
    def space(self) -> str:
        return " "

    @property
    def newline(self) -> str:
        return "\n"

    def wrap_link(self, text: str, url: str) -> str:
        return f"[{text}]({url})"

Then build a pipeline from it:

from krrood.entity_query_language.verbalization.pipeline import VerbalizationPipeline
from krrood.entity_query_language.verbalization.rendering.renderer import ParagraphRenderer

text = VerbalizationPipeline(ParagraphRenderer(MarkdownFormatter())).verbalize(query)

API Reference#

Entry points & engine#

Grammar / dispatch#

VerbalizationFragment hierarchy#

Rendering passes & output#

Microplanning & utilities#

Vocabulary (lexicon)#

  • Keywords / Logicals / Aggregations / Copulas / Operators / Articles / Prepositions / Conjunctions / Punctuation / Pronouns / RangePhrases / CoindexedOperators / CoindexedPhrases / Absence / NonExistence / SetMembership / Specificity / ExistentialPhrase / FallbackNouns

  • PlainWord / RoleWord / VocabEnum

References#

The reference notes throughout the verbalization source (the :cite: annotations on the grammar rules, planners, assemblers, and realisation passes) resolve to the entries collected in the Bibliography.