HoloLang / Syntax
Field references, literals, operators, date operations and aggregate functions: the parts every rule is built from.
Syntax Reference
Field References
Fields from the data model are referenced with the @ prefix. Field names must match the data structure exactly:
# Simple field references
@company_siret
@f110_net_n
@exercise_start_date
# Nested field access (dot notation for objects with subproperties)
@salary.amount
@employee.address.city
@grossremuneration.iso4217currencycodeFor nested objects, use dot notation to access subproperties. Each part of the path must exist in the data.
The whole payload.@document is a
reserved root naming every extracted field as one value. It is legal wherever a container
is (STRINGIFY(@document) hands the whole document to an LLM_JUDGE
prompt) and is a type error in a comparison. @document.total is @total
spelled from the root, indexes included (@document.reps[0].nom). Like
enriched, it wins over a schema field of the same name. In a dossier's validity
rules @document is the document being judged; in a dossier's consistency rules the
bare requirement inside STRINGIFY (STRINGIFY(@case.documents.releve))
is the bound document's whole payload.
# Every field of the document, as one JSON value
ASSERT LLM_JUDGE(CONCAT('Is this a genuine payslip? ', STRINGIFY(@document)))
ASSERT STRINGIFY(@document) CONTAINS 'SARL'
# A field spelled from the root - the same thing as @total
ASSERT @document.total == @document.subtotal + @document.tax WITHIN 0.01Literals
Numbers (integers and decimals, can be negative) and strings (single or double quoted):
# Numbers
100
-50.5
0
# Strings
'SARL'
"active"Operators
| Operator | Type | Description |
|---|---|---|
| == | Comparison | Equal |
| != | Comparison | Not equal |
| >< >= <= | Comparison | Greater/less than (or equal) |
| IN NOT IN | Membership | Is the left value one of the values on the right? The right side takes three forms, and the spelling decides which: a parenthesised list of literals (@doc_type IN ('18', '24')); an array expression (ARRAY_AGG(@reps[].nom), ARRAY(@a, @b), ARRAY_CONCAT, ARRAY_SLICE, ARRAY_REVERSE), tested element by element as a set of whole values, as the list is; or a single field carrying several values in one string, such as a sealed list of forenames, which splits on / and , only, so MARIE CLAIRE and Jean-Pierre stay whole. Case and accents fold as with == in every form. The left side is never split, and an empty right side fails rather than passing |
| === !== | Comparison | Strictly equal / not equal. No case or accent folding, no numeric or date conversion. Use when the exact characters matter |
| CONTAINS NOT CONTAINS | Substring | Does the right value appear inside the left one? The mirror of IN, which looks for a whole value among several. Case and accents fold as with ==. When the left side is an array, it matches if any element contains the text. An empty right side always fails, and a missing left side is false, so NOT CONTAINS passes on a missing field, as NOT IN does |
| AND OR NOT | Logical | Combine and negate conditions (case-insensitive). NOT binds tighter than AND/OR, which have equal precedence and group left to right. Parenthesise whenever both appear |
| + - * / | Arithmetic | Arithmetic in formulas and on the right side of a comparison, numeric only. * and / bind tighter than + and - |
| ^ | Arithmetic | Power. Binds tightest and groups right to left, so 2 ^ 3 ^ 2 is 512. 0 ^ -1, a negative base with a fractional exponent, and overflow all fail the rule rather than returning a non-number |
| & | Text | Builds text. + never concatenates, so a rule means one thing whatever the extractor wrote. & binds loosest, so 'total: ' & @ht + @tva joins the sum. An absent field renders as an empty string, a whole number loses its decimal point, a date renders ISO; a list or object operand fails the rule |
Precedence, tightest first:
( ) → functions → ^ → */
→ +- → & → comparisons
→ NOT → AND/OR.
The left side of a comparison takes a field or a function call, never bare
arithmetic: ASSERT @a - @b > 0 does not parse. Write
ASSERT ABS(@a - @b) > 0, or move the arithmetic to the right side.
Functions and Predicates
Every function below returns a value and may therefore lead a comparison, which plain arithmetic cannot. The predicates are the exception: they answer a yes/no question and belong where a condition goes.
| Signature | Description |
|---|---|
| @field IN ('a', 'b', 'c') @field NOT IN ('a', 'b') ISPRESENT(@field) ISBLANK(@field) DDOC_BENEF_NAME_MATCH(@enriched.<f>.beneficiaire, @firstname, @lastname) | Predicates. Is the field filled in? Legal inside IF, ASSERT and WARN IF, never inside a calculation. An empty string, a whitespace-only string, an empty list, an empty object and an absent field are all blank; 0 and false are present. This is the same definition REQUIRED uses, so the operand is a plain @field: ISBLANK(@items) already is the array test |
| LLM_JUDGE(prompt) STRINGIFY(value) | Asks a model to decide a question you compose. You build the whole prompt with CONCAT or &, dropping values into it with STRINGIFY, which renders any value as JSON text and is the only way an object or a list reaches a string (CONCAT renders a container as empty on purpose). It answers true or false. The only construct here whose answer is not a pure function of your data: it calls out to a language model, so it costs money and latency and two evaluations may differ once the cached verdict expires. Reach for it only when no deterministic comparison will do (SLUGIFY(@a) == SLUGIFY(@b) and CONTAINS are free), and gate it behind a cheap condition such as IF @a != @b THEN ASSERT LLM_JUDGE(…). It fails closed: a timeout or an unreadable answer fails the rule rather than passing it, and a judge that is not configured at all reports the rule as an error instead of a failure |
| STARTS_WITH(@field, prefix) ENDS_WITH(@field, suffix) CONTAINS_SUBSTR(@field, needle) | String predicates, BigQuery's spellings. They test a prefix, a suffix or a substring, NOT presence, so the blankness rules above do not apply. Case and accents are folded on both sides, and an empty prefix/needle is false rather than vacuously true. Like the row above they go where a CONDITION goes, never inside a calculation, so write ASSERT STARTS_WITH(@iban, 'FR') and never == 'true'. CONTAINS_SUBSTR is the function spelling of the CONTAINS operator and behaves identically, list haystacks included |
| CONTAINS_ALL_WORDS(haystack, needle) | Bags of words, not characters. True when every WORD of the needle appears somewhere in the haystack, in any order and not necessarily next to each other. CONTAINS_ALL_WORDS('GABRIEL RAPHAEL BRAMI', 'GABRIEL BRAMI') is true where CONTAINS is false. This is what survives a middle name the page omits, a NOM Prénom inversion, or a SARL that moved to the other end. Whole words only: 'GABRIELLE BRAMI' does not contain the words of 'GABRIEL BRAMI', which is what separates it from CONTAINS_SUBSTR. Case, accents and punctuation are folded; the haystack comes first and may carry extra words, so either direction is CONTAINS_ALL_WORDS(@a, @b) OR CONTAINS_ALL_WORDS(@b, @a). An empty needle and a missing haystack are both false. A list haystack matches when ANY element carries every word. On people, read it as "could be the same person", not "is": a sibling's RAPHAEL BRAMI is also inside GABRIEL RAPHAEL BRAMI, so pin the identifying words or add a second check where that would matter |
| ALL(condition) ANY(condition) | Quantifiers over one array's elements. The condition is evaluated once per element, and its [] references resolve to that element: ALL(@releves[].solde > 0), ANY(@representants[].nom == @nom_client). The condition may combine AND/OR/NOT and nest ISPRESENT or a string predicate. It may also read a scalar field beside the element references, and carry arithmetic on the RIGHT side of a comparison (ALL(@plafond >= @lines[].qty * @lines[].price)). It needs at least one [] reference, and all of them must be on the same array. It may not nest an aggregate or another quantifier. Like ISPRESENT, ALL and ANY are predicates: they go where a condition goes (IF, ASSERT, WARN IF, under NOT), and ALL(…) == 'true' is a parse error. Never vacuous: an empty or absent array is false for both, so “no element does” is written NOT ANY(…). A failure reports a tally, for example ALL(@bs[].solde > 0) [2 of 3 elements]. The names are ours: SQL's ALL/ANY are subquery comparison operators, a different shape |
| CONCAT(a, b, ...) LOWER(value) UPPER(value) LENGTH(value) LEFT(value, n) RIGHT(value, n) SUBSTRING(value, position[, length]) | String functions, BigQuery's spellings. Unlike the predicates above they return a value, so they go inside a calculation or on either side of a comparison: ASSERT LENGTH(@iban) == 27, ASSERT LEFT(@siret, 9) == @siren. CONCAT takes two or more arguments and joins them as text, like &, with one difference: & fails the rule on a list or an object, CONCAT renders it as empty. Positions are 1-based and a negative SUBSTRING position counts back from the end, as in BigQuery: SUBSTRING(@iban, -4) is the last four characters. A length of zero or less gives an empty string rather than an error. A missing value reads as the empty string, where BigQuery would return NULL. A number that is a whole number renders without its .0, so LENGTH of a 13-digit invoice number is 13. A list or an object renders as empty; use STRINGIFY to turn one into text |
| SLUGIFY(value) | Compare two human-typed strings for “the same thing”. Removes accents, lower-cases, and turns every run of characters that are not letters or digits into one -: SLUGIFY("Éditions Château d'Yquem, SARL") is editions-chateau-d-yquem-sarl. So SLUGIFY(@a) == SLUGIFY(@b) ignores case, accents, punctuation and spacing without a regex. Ours, not BigQuery's. Word order still counts; for that use CONTAINS_ALL_WORDS |
| IFNULL(value, fallback) | The value, or the fallback when it is absent by the definition above. Arithmetic on a missing field fails the rule by design, so this is how an optional field enters a formula: @total = @base + IFNULL(@bonus, 0). Only absence takes the fallback; an error inside the first argument still fails the rule |
| MIN(a, b, ...) MAX(a, b, ...) | Smallest / largest of two or more values. The argument count decides which function you get: with a SINGLE bracketed argument (MIN(@bank_statements[].start_date)) they are the array aggregates instead (see Aggregate Functions). A single argument without [] is an error |
| ROUND(value[, digits]) | Rounds half up, the way an invoice does: ROUND(2.5) is 3. Without digits, rounds to an integer |
| ABS(value) | Absolute value of a numeric expression. Wrap a difference to compare a gap without caring about its direction. For “equal within a tolerance” prefer WITHIN, which already means exactly that |
| DATEDIFF(end, start, unit) | Signed count of complete calendar units from start to end, first date minus second. 17 years and 11 months is 17 YEAR, which is what an age check means. Arguments are date terms only: a field, DATE(...), DATETIME(...) or TODAY |
# Presence as a question: an optional block that must be complete once started
IF ISPRESENT(@guarantor) THEN REQUIRED @guarantor_income
IF ISBLANK(@married_name) THEN REQUIRED @birth_name
WARN IF ISBLANK(@iban)
ASSERT NOT ISBLANK(@tax_number)
# Substring, folded like ==
ASSERT @line_label CONTAINS 'urssaf'
ASSERT @line_label NOT CONTAINS 'cancelled'
IF @title CONTAINS 'invoice' THEN REQUIRED @total_incl_tax
# Every element / at least one element - conditions, never vacuous
ASSERT ALL(@statements[].balance > 0)
IF ANY(@representatives[].role == 'Manager') THEN REQUIRED @company_register
WARN IF NOT ALL(@statements[].is_valid == 'false' OR @statements[].balance > 0)
# Membership in an array expression, element by element
ASSERT @client_name IN ARRAY_AGG(@representatives[].name)
ASSERT @naf_code NOT IN ARRAY_CONCAT(@excluded_codes, @suspended_codes)
# Fallbacks, so an optional field can enter a formula
@total = @base + IFNULL(@bonus, 0)
ASSERT IFNULL(@married_name, @birth_name) == @account_holder
# Scalar helpers
ASSERT @rent <= MAX(@income_1, @income_2) / 3
@vat = ROUND(@amount_excl_tax * 0.2, 2)
# Age and gaps
ASSERT DATEDIFF(TODAY, @birth_date, YEAR) >= 18
ASSERT ABS(DATEDIFF(@signature_date, @issue_date, DAY)) <= 30
# Power, and building text
@area = @side ^ 2
@full_name = @first_name & ' ' & @last_name
ASSERT @account_holder == @first_name & ' ' & @last_nameDate Operations
HoloLang supports date literals, the TODAY keyword for relative date checks, and date interval tolerances.
Date Literals
Use DATE(year, month, day) for fixed date comparisons:
# Compare against a fixed date
ASSERT @due_date > DATE(2026, 01, 15)
ASSERT @start_date < @end_date
# Equality with date interval tolerance
ASSERT @filing_date == DATE(2026, 01, 15) WITHIN 7 DAYTODAY - Relative Date Checks
Use TODAY for dynamic date comparisons relative to the current date. Combine with + or - and a date interval for offset checks:
# Must be in the future
ASSERT @due_date > TODAY
# Must be within the last 6 months
ASSERT @statement_date > TODAY - 6 MONTH
# Must not be more than 1 year out
ASSERT @end_date < TODAY + 1 YEAR
# Must be within 3 weeks of today
ASSERT @filing_date == TODAY WITHIN 3 WEEK
# Conditional: active items must expire in the future
IF @is_active == 1 THEN ASSERT @expiry_date > TODAY| Interval Unit | Example | Description |
|---|---|---|
| DAY | TODAY + 30 DAY | Calendar days |
| WEEK | TODAY - 3 WEEK | Calendar weeks (7 days) |
| MONTH | TODAY + 6 MONTH | Calendar months (Jan 15 + 1 MONTH = Feb 15) |
| QUARTER | TODAY + 2 QUARTER | Calendar quarters (3 months) |
| YEAR | TODAY - 1 YEAR | Calendar years |
TODAY is case-insensitive (today, Today all work). Date intervals use calendar-based arithmetic: TODAY + 1 MONTH from January 31 gives February 28 (not 30 days later).
Aggregate Functions
Aggregate functions operate on array fields. Use [] to indicate array iteration:
The one bracket rule.[] means
per element, and it only ever lives inside an aggregate's argument:
COUNT(@representants[]), SUM(@items[].amount),
ARRAY_AGG(@reps[].nom). A bare array path is the array as ONE value, which is
what the ARRAY_* value functions take: ARRAY_LENGTH(@representants),
ARRAY_TO_STRING(@representants, ', '), STRINGIFY(@representants).
A [] anywhere else is a parse error with a hint, and a path carries at most one
[].
| Function | Description | Syntax |
|---|---|---|
| SUM | Sum of field values in array | SUM(@array[].field) |
| COUNT | Number of items in array | COUNT(@array[]) |
| AVG | Average of field values | AVG(@array[].field) |
| ARRAY_AGG | Collect one value per element into an ARRAY. ORDER BY sits inside the parentheses (BigQuery's slot), WHERE outside like every aggregate; both name element-relative fields (dotted allowed). Skips absent values by COUNT's non-null rule, so ARRAY_LENGTH(ARRAY_AGG(@x[].f)) == COUNT(@x[].f) always holds | ARRAY_AGG(@array[].field [ORDER BY field [ASC|DESC]]) [WHERE field OP value] |
| STRING_AGG | Join one value per element into a single string; the delimiter defaults to ',' (BigQuery's default) | STRING_AGG(@array[].field[, delimiter] [ORDER BY ...]) [WHERE ...] |
| MIN MAX | Smallest / largest element: numbers, dates AND strings (dates in date order). One bracketed argument; two or more arguments is the scalar variadic form above. An empty or fully-filtered set is absent (quiet), like any missing path | MIN(@array[].field) [WHERE ...] |
| WHERE | Filter array items before aggregating (compare one item field to a literal) | SUM(@array[].field) WHERE type == 'x' |
# Sum all amounts in line items
@total = SUM(@items[].amount)
# Count number of transactions
ASSERT COUNT(@transactions[]) > 0
# Verify total matches sum of parts
ASSERT @invoice_total == SUM(@line_items[].subtotal) WITHIN 0.01
# Check average value
ASSERT AVG(@scores[].value) >= 50
# Combine with other expressions
@grand_total = SUM(@items[].amount) + @tax + @shipping
# Filtered aggregates (WHERE clause on an item field)
@revenue = SUM(@items[].amount) WHERE category == 'Revenue'
ASSERT COUNT(@transactions[]) > 0 AND SUM(@transactions[].amount) != 0
# Collect elements into an array, and scan it
ASSERT ARRAY_AGG(@representants[].nom) CONTAINS @nom_client
# Join elements into one string (delimiter defaults to ',')
ASSERT STRING_AGG(@lignes[].description, ' | ') CONTAINS 'panneau'
# Oldest / newest element - MIN/MAX work on numbers, dates and strings
ASSERT MIN(@bank_statements[].start_date) <= TODAY - 3 MONTH
ASSERT MAX(@bank_statements[].end_date) >= TODAY - 1 MONTHSUM(@x[].amount)). The element-expression spelling below is loud instead: the spelling chooses the contract.
Element Expressions
The aggregate argument may be a full expression over one array's [] references
(arithmetic, CONCAT, any value function), evaluated once per element:
# Per-element arithmetic: line totals against the invoice total
ASSERT SUM(@lignes[].quantite * @lignes[].prix_unitaire) == @total WITHIN 0.01
# Per-element composition, ordered, filtered, joined into one readable string
ASSERT ARRAY_TO_STRING(ARRAY_AGG(CONCAT(@bank_statements[].start_date, ' - ', @bank_statements[].end_date) ORDER BY start_date) WHERE is_valid == 'true', ', ') CONTAINS '2026-01-01'
# A scalar reference beside element references is legal - it reads the
# document once per element
ASSERT ARRAY_AGG(CONCAT(@societe, ':', @reps[].nom)) CONTAINS 'ACME'Three rules apply:
- All
[]references in one aggregate must share the same array. Two different arrays are refused with an explanation. - Aggregates cannot nest.
- The expression form is loud on bad data: a non-numeric string inside
SUM's arithmetic errors the rule, where the bare-path form keeps its historical quiet skip.
Quantifiers: ALL / ANY
ALL(condition) and ANY(condition) evaluate one condition once per
element of one array, the same element context an aggregate's argument gets. They are
predicates, not values: they go where a condition goes and take NOT,
AND and OR.
# Every statement in credit
ASSERT ALL(@releves[].solde > 0)
# A condition on any element, gating a requirement
IF ANY(@representants[].qualite == 'Gérant') THEN REQUIRED @kbis
# Composed per element; a scalar field beside the element references is legal
WARN IF NOT ALL(@bs[].is_valid == 'false' OR @bs[].solde > 0)
ASSERT ANY(@representants[].nom == @nom_client)
# Predicates nest inside; arithmetic is allowed on the RIGHT side
ASSERT ALL(ISPRESENT(@representants[].nom))
ASSERT ALL(@plafond >= @lines[].qty * @lines[].price)
# "No element does" - because an empty array is false for ANY as well
ASSERT NOT ANY(@bs[].solde < 0)Four rules apply:
- The condition holds at least one
[]reference, all on one array. Two roots are refused. - No aggregate or quantifier nests inside.
- A quantifier is never a value, so
ALL(…) == 'true'is a parse error. - Neither is ever vacuous. An empty or absent array is
falseforALLas forANY, so an array with nothing in it never passes as “every element passed”. Write “no element does” asNOT ANY(…).
A failing quantifier reports a tally, ALL(@bs[].solde > 0) [2 of 3 elements], and the same
2/3 lands in the rule's actual values. In a dossier's consistency rules the array may
also be a requirement's documents, for example ALL(@case.documents.releves[].siren == @case.documents.kbis.siren);
see the dossier documentation.
Array Value Functions
These take the bare array: the array as one value, no [].
Names and semantics are BigQuery's; the recorded divergences follow the language's own
missing-path rule (quiet absence) rather than BigQuery's NULL errors.
| Signature | Description |
|---|---|
| ARRAY(@a, @b, ...) | Build an array from scalar values. SKIPS absent values (BigQuery errors on NULL; recorded divergence). An array argument is a loud error pointing at ARRAY_CONCAT |
| ARRAY_CONCAT(@arr1, @arr2, ...) | Merge arrays into one; an absent operand reads as empty |
| ARRAY_LENGTH(@arr) | Number of elements. An absent array is 0; a present scalar raises loudly |
| ARRAY_TO_STRING(@arr, delimiter) | Join the elements into text. THE way an array reaches CONCAT chains, failure messages and LLM_JUDGE prompts as readable text. Absent → ''; object elements raise loudly (a container has no text value) |
| ARRAY_FIRST(@arr) ARRAY_LAST(@arr) | First / last element. Empty or absent → quietly absent (BigQuery raises; recorded divergence, because our missing-path rule is quiet) |
| ARRAY_SLICE(@arr, start, end) | BigQuery's contract verbatim: 0-based, BOTH offsets inclusive, negative counts from the end, out-of-range clamps |
| ARRAY_REVERSE(@arr) | The elements in reverse order |
Where an array value can flow. An array value is accepted in four places:
- as an
ARRAY_*function's array operand; - inside
STRINGIFY; - as the LEFT (haystack) side of
CONTAINS,CONTAINS_SUBSTRandCONTAINS_ALL_WORDS, whereARRAY_AGG(@reps[].nom) CONTAINS 'DUPONT'scans the elements; - as the RIGHT side of
IN/NOT IN, where it is a set of whole values.@nom_client IN ARRAY_AGG(@reps[].nom)tests each element with the engine's own==, asIN ('a', 'b')does, and an empty set isfalse, never an error.
Only an array expression gets this;
the older @x IN @field keeps its split-the-string reading. Scalar positions refuse
an array at authoring time: ARRAY_AGG(...) == 'x', arithmetic, CONCAT
arguments (use ARRAY_TO_STRING), and LLM_JUDGE's prompt directly
(wrap it in STRINGIFY or ARRAY_TO_STRING).
Element Indexing
A path may select one element by position: 0-based like BigQuery's bare subscript, and
negative counts from the end (our recorded divergence: BigQuery only has that in
ARRAY_SLICE). By deliberate contrast, SUBSTRING,
LEFT and RIGHT stay 1-based, BigQuery's own split.
# First and last element
ASSERT @representants[0].nom == @nom_dirigeant
ASSERT @representants[-1].nom == @nom
# Composes with dotted paths and with iteration
ASSERT @a[0].b[2].c == 5
ASSERT COUNT(@reps[0].adresses[]) > 0
# Out of range resolves quietly to absent, like any missing path - gate it:
IF COUNT(@representants[]) > 0 THEN ASSERT ISPRESENT(@representants[0].nom)An indexed path is legal everywhere a plain reference is (comparisons,
ISPRESENT/ISBLANK, function arguments), but not as a formula
assignment target.