Style guide

Grid Style Guide

Grid Style Guide

This is the canonical authoring style for Grid models. The grammar accepts more forms than this guide teaches; the canonical style picks one preferred way to spell each idea so models stay consistent across authors and tools.

This guide is normative for:

  • Models in examples/canonical/.
  • Models used in documentation.
  • Models used in regression tests and validation fixtures.
  • Models written by AI agents (see ai-agent-guide.md).

It is recommended for any shared or production model.

Looking for the formal contract? That's docs/specs/parser_authoring.md.


1. Why Have A Style Guide

Grid intentionally accepts a wide range of equivalent forms. That flexibility is useful for migration, exploration, and parser coverage, but it makes models feel inconsistent when reviewed across teams.

The canonical profile makes models:

  • Readable — one preferred spelling per idea.
  • Predictable — review and refactor without ambiguity.
  • Portable — preferred forms map cleanly to both runtimes.
  • Diff-friendly — small changes produce small diffs.
  • Test-friendly — validators and parsers exercise the same surface.

If your team needs a different convention, fork the guide locally — just pick one rule per idea and stick to it.


2. The Canonical Profile (Cheat Sheet)

Idea Prefer Avoid
Conditional THEN ... ELSE ? :
Null fallback DEFAULT ??
Local bindings DO ... END top-level LET(...)
Multi-branch (same subject) MATCH(subject, ...) repeated IF(=, ...)
Multi-branch (different conditions) THEN ... ELSE chained CASE WHEN (acceptable but not preferred)
Lambdas (x) => ... named arrows LAMBDA(x, ...) (acceptable)
Higher-order helpers MAP, REDUCE, SCAN, MAKEARRAY, BYROW, BYCOL hand-rolled loops
Async value ~= for lazy / one-shot, = for eager mixing both for the same value
Error guard DEFAULT then IFERROR, TRY nested IF(ISERROR(...))
Type tag Use on outputs and crossing-boundary values Tag every intermediate
Strings "double quotes" 'single quotes'
Section comments # Heading once per section One comment per line
Rules WHEN ... THEN ... END blocks assignment-level schedule modifiers

3. Model Shape

Every shared model follows this shape:

MODEL "Name"
DESCRIPTION "What this model does."
RUNTIME "lua_generated" | "ts"
VERSION "x.y.z"
AUTHOR "owner"
TAGS "tag1", "tag2"
 
# Inputs
A1 as type = ...
 
# Derived calculations
B1 as type = ...
 
# Final outputs
C1 as type = ...
 
# Rules (any runtime)
WHEN ... THEN ... END
 
END MODEL

The pattern is: header → inputs → derivations → outputs → rules → END.

3.1 Picking The Runtime

Pick lua_generated whenever you can. It compiles to Redis Lua, ships as a self-contained Redis Functions library, and supports the full language including rule blocks (WHEN, EVERY, AT) and rule-action assignment modifiers (+=, ?=, …). The TS GridService orchestrates rule waves and arms a smart-wakeup setTimeout per model, so behavior is identical to the ts runtime.

Switch to ts only when you need:

  • An in-process runtime for development without a Redis dependency.
  • A scenario where unit tests are easier to write against an in-process runtime than against a real Redis.

All language features — including spatial references (ABOVE, BELOW, LEFT, RIGHT, NEIGHBORS) — are supported on every runtime, so the choice is operational, not a feature gate.

When in doubt, start with lua_generated.

lua_interpreter is a third option: same Lua-in-Redis semantics as lua_generated but ships the model as a JSON IR loaded by a shared runtime script. Pick it when you have many models in the same deployment and want them to share one runtime artifact rather than each compiling its own Lua library.

3.2 Sectioning

Group statements into clear sections with a single # heading:

# Inputs
A1 as currency = 100000
A2 as percentage = 7pct
 
# Derivations
B1 as currency = A1 * (1 - A2)
 
# Outputs
C1 = B1 > 50000 THEN "ok" ELSE "small"

Don't comment every line. Section headings carry the structural narrative.


4. Naming

4.1 Cell Names

A1 notation is the default. Reserve named references for things that genuinely have a name in the domain:

revenue = SUM(A1:A12)            # named — used many places
B5 = revenue * 0.21              # OK to reference by name

Don't rename single-use intermediates. A3 is fine.

4.2 Lambda Parameters

Use parameter names that reveal meaning:

MAP(units, prices, (units, price) => ROUND(units * price, 2))     # good
MAP(units, prices, (a, b) => ROUND(a * b, 2))                     # weak
MAP(units, prices, _1 * _2)                                       # placeholder OK for trivial cases

Placeholder lambdas (_, _1, _2) are reserved for short higher-order operations and pipe steps where the operation is self-evident.

4.3 Symbol Literals

Use :identifier for enum-like state labels:

status = MATCH(score, score > 0.9 -> :excellent, score > 0.7 -> :good, _ -> :poor)

MATCH is a function call, so its arms may wrap across lines if the single-line form gets too long:

status = MATCH(score,
  score > 0.9 -> :excellent,
  score > 0.7 -> :good,
  _           -> :poor
)

Symbol values display nicely, sort consistently, and signal "this is a state, not free text".


5. Assignments

5.1 Use = By Default

A1 = 10                         # eager
A2 as currency = A1 * 100         # eager + type tag

5.2 Use ~= For Lazy / Expensive

A3 ~= ML_SCORE(features)        # lazy external
A4 ~= LARGE_MATRIX_INVERT(M)    # lazy expensive

5.3 Use ?= Sparingly

?= preserves a previous value when the RHS errors. Use it when an input might be temporarily unavailable but you want to keep the last known good value:

A5 = 0
A5 ?= MAYBE_FAIL_FETCH()

Don't use ?= as a substitute for proper error handling (DEFAULT, IFERROR, WITH).

5.4 Use Compound Assignments For Counters

counter += 1
total += amount

Only inside imperative-feeling sections. Most cells are functional and should use =.

5.5 Tag Outputs, Not Every Intermediate

A1 as currency = 100000           # input — tag
B1 as currency = A1 * 1.05        # output — tag
B2 = B1 + 100                   # intermediate — usually no tag

Tag inputs (so callers know the shape), tag outputs (so consumers format correctly), and skip tags on internal intermediates unless they carry meaning.


6. Branching

6.1 Single Condition: THEN ... ELSE

status = score > 0.7 THEN "high" ELSE "low"

6.2 Multiple Tiers: Chain THEN ... ELSE

Most-specific to least-specific:

grade = score >= 90 THEN "A" ELSE score >= 80 THEN "B" ELSE score >= 70 THEN "C" ELSE "F"

Chained THEN ... ELSE is single-line. If it doesn't fit, switch to CASE WHEN (which spans lines).

6.3 Same Subject, Many Values: MATCH

color = MATCH(status, "draft" -> :gray, "pending" -> :amber, "approved" -> :green, _ -> :red)

Use MATCH whenever the branches all compare against the same subject. The _ arm is required if you don't enumerate every value and want a default. MATCH arms may also wrap across lines as a table — see §4.3 above.

6.4 Multi-Condition Tabular Form: CASE WHEN (Acceptable)

When several distinct conditions need separate clauses, CASE WHEN reads well as a table:

grade = CASE
  WHEN score >= 90 THEN "A"
  WHEN score >= 80 THEN "B"
  WHEN score >= 70 THEN "C"
  ELSE "F"
END

But chained THEN ... ELSE is also fine, and is the canonical pick for short chains.


7. Local Bindings

Use DO ... END to name intermediate values:

B1 as currency = DO
  revenue = SUM(A1:A12)
  cost = SUM(B1:B12)
  revenue - cost
END

Prefer DO over top-level LET(...):

# Avoid
B1 = LET(revenue, SUM(A1:A12), cost, SUM(B1:B12), revenue - cost)

DO is what LET desugars to. Authoring with DO keeps the visual structure of "name some things, then return one thing".


8. Pipes

Use pipes when a value moves through a short, obvious left-to-right chain:

result = raw_value >> ABS() >> ROUND(2)
filtered = data >> FILTER(_, _ > 0) >> SORT(_) >> TAKE(10)

Avoid pipes when:

  • The chain is one step (just call the function directly).
  • The chain has nested branching (use DO).
  • The placeholder slot needs to appear in unusual positions (use a named lambda).

9. Higher-Order Helpers

A3 = MAP(A1#, A2#, (units, price) => ROUND(units * price, 2))
A4 = SCAN(0, A3#, (acc, value) => ROUND(acc + value, 2))
A5 = REDUCE(0, A3#, (acc, value) => ROUND(acc + value, 2))
B1 = BYROW(matrix, row => SUM(row))
B2 = BYCOL(matrix, col => AVERAGE(col))
B3 = MAKEARRAY(rows, cols, LAMBDA(r, c, INDEX(matrix, r, c) * scale))

Style points:

  • Name lambda parameters meaningfully.
  • One arrow lambda per helper — don't nest deep lambdas inside lambdas.
  • For simple elementwise operations, prefer SIN.(arr) broadcast over MAP(arr, SIN(_)).

10. Error Handling

10.1 Default Order Of Preference

  1. DEFAULT for blank/error fallback to a value.
  2. IFERROR / IFNA for explicit error handling.
  3. TRY ... ELSE inline form for one-off guards.
  4. WITH ... THEN ... ELSE for multi-step external chains.
  5. ASSERT for invariant checking on inputs.

10.2 Examples

# Cheap fallback
B1 as currency = ROUND(amount * (rate DEFAULT 1.08), 2)
 
# Explicit error handling
B2 = IFERROR(VLOOKUP(...), "n/a")
 
# Inline guard
B3 = TRY 1 / divisor ELSE 0
 
# Multi-step chain
B4 = WITH data = HTTP_JSON(url), first = data.results[1]
THEN first.name ELSE "unknown"
 
# Input invariant
B5 as percentage = input ASSERT input BETWEEN 0 AND 1

Avoid nesting IF(ISERROR(...)) chains — they're harder to read than the canonical alternatives.


11. External Functions

Pair every external call with a DEFAULT:

fx_rate = FX_RATE("EUR", "USD")
amount_usd as currency = ROUND(amount_eur * (fx_rate DEFAULT 1.08), 2)

Use ~= for external calls that are not on the hot path:

nightly_score ~= ML_SCORE(features)

Don't put external calls inside frequently-recomputed expressions unless you want every recompute to enqueue a job. Cache them at a named cell.


12. Rules

When a model needs reactive behavior, use full rule blocks rather than schedule modifiers:

# Good
WHEN A1 > 100 THEN
  C1 = "alert"
  C2 = NOW()
END
 
EVERY duration"PT15M" SKIP MISSED THEN
  D1 = D1 + 1
END
 
AT dt"2026-12-31T23:59:00Z" BACKFILL THEN
  E1 = TRUE
END
# Avoid in shared models
G1 = NOW() EVERY cron"0 * * * *" BACKFILL

Schedule modifiers are convenience sugar. They're fine for one-off scripts but blur the line between formula and rule in larger models.

Always include a missed-run policy (SKIP MISSED or BACKFILL).


13. Type Tags

13.1 Use Tags For

  • Inputs that need validation.
  • Outputs that need formatting (currency, percentage, date).
  • Values crossing model or API boundaries.
  • Anything where semantic meaning matters more than brevity.

13.2 Don't Use Tags For

  • Trivial scratch intermediates.
  • Boolean flags.
  • Counters.

13.3 Common Tags

Tag When to use
currency Money amounts
percentage Rates expressed 0..1 or 0..100
bps Basis points
score ML or rating scores
date / datetime Calendar values
duration Time spans
unit:USD etc. Specific currency codes

14. Comments

# Section heading
 
A1 = 100         # Inline comment ONLY when the line is non-obvious
 
/* Block comment for a multi-paragraph
   explanation that wouldn't fit on one
   line. */

Avoid:

  • One comment per line restating the formula (# multiply by tax rate).
  • Block comments wider than 80 columns.
  • Decorative comments (banners, ASCII art, separators).

15. Spacing And Layout

  • One blank line between sections.
  • No blank lines inside a DO block, a rule body, or a tightly related sequence of cells.
  • Use spaces around operators: A1 + A2, not A1+A2.

15.1 When To Wrap Across Lines

Grid lets any expression inside (...), [...], or {...} span multiple lines (see reference.md §14.1). Use that freely when it makes the formula easier to scan — not for its own sake.

Wrap when:

  • A function call has more than ~3 meaningful arguments, or any argument is itself a non-trivial expression.
  • A MATCH has more than ~3 arms, or the patterns/results are wide enough that side-by-side reading suffers.
  • An object literal has more than ~3 fields.
  • A pipe (>>) chain has more than ~2 stages.
  • A boolean predicate or arithmetic expression mixes operators of different precedence and benefits from one term per line.

Keep it on one line when it already fits and reads cleanly — most two- and three-argument calls don't need wrapping.

15.2 Wrapping Conventions

When you do wrap, follow these conventions so models stay legible across a team:

# Function calls: one argument per line, two-space indent, closing
# paren on its own line.
A1 = MATCH(status,
  "draft"    -> :gray,
  "pending"  -> :amber,
  "approved" -> :green,
  _          -> :red
)
 
# Object literals: one field per line, trailing comma optional,
# closing brace on its own line at the parent indent.
A2 = {
  name: "Acme",
  founded: 1999,
  active: TRUE
}
 
# Comprehensions: keep the head expression on the first line, then
# put each FOR / IF clause on its own line.
A3 = [
  row.revenue
  FOR row IN sales
  IF row.region = "NA"
]
 
# Pipes: wrap the whole chain in parens, with the leading >> at the
# start of each continuation line.
A4 = (data
  >> FILTER(_, _ > 0)
  >> SORT(_)
  >> TAKE(10))
 
# Long predicates: parens + leading operator per line.
A5 = (
  is_active
  AND tier = :gold
  AND balance > 0
)

Align -> arrows in MATCH arms when the patterns are short and similar-shaped (it makes the table effect work). Don't force alignment when patterns vary widely — uneven padding is worse than no padding.


16. What's Out Of Style

These forms work but are not canonical for shared models:

Form Why avoid in shared models
LET(...) at top level Use DO ... END
? : ternary Use THEN ... ELSE
?? Use DEFAULT
'single quotes' for strings Use "double"; single is for sheet qualifiers
Schedule modifier on a single cell Use a full rule block
Nested IF(ISERROR(...)) chains Use IFERROR, WITH, or DEFAULT
Untyped published outputs Add an as <tag> annotation
Heavy placeholder use in long expressions Use named arrow lambdas

The parser still accepts all of these. They're just not preferred for shared, reviewed, or AI-generated code.


17. Worked Comparison

A model written in two styles:

17.1 Loose Style (Acceptable, Not Canonical)

A1 = 100
B1 = A1 ?? 0
C1 = LET(t, A1 * 1.21, t)
D1 = A1 > 50 ? "big" : "small"
E1 = IF(ISERROR(FX_RATE("EUR","USD")), 1.08, FX_RATE("EUR","USD"))
F1 = NOW() EVERY cron"0 * * * *" SKIP MISSED

17.2 Canonical Style

MODEL "Example"
DESCRIPTION "Show the canonical style."
RUNTIME "lua_generated"
VERSION "1.0.0"
 
# Inputs
A1 as currency = 100
 
# Derivations
B1 as currency = A1 DEFAULT 0
C1 as currency = DO
  total = A1 * 1.21
  total
END
D1 = A1 > 50 THEN "big" ELSE "small"
E1 as fx_rate = FX_RATE("EUR", "USD") DEFAULT 1.08
 
# Periodic refresh
EVERY cron"0 * * * *" SKIP MISSED THEN
  F1 = NOW()
END
 
END MODEL

The canonical version is longer but it's structurally clear and tooling-friendly.


18. Linting And Validation

A canonical-style linter is not yet shipped. The current validators are:

  • Build-time diagnostics from the parser/model builder.
  • The seven canonical models in examples/canonical/ as a regression fixture.
  • The npm test -- canonicalModels test target.

When the canonical-style linter ships, it will check the rules in this guide programmatically.


19. See Also