Browse documentation
Docs/Language

Assignments

Assignments are the primary statement form. They define or update addressable bindings; a cell is the coordinate-addressed kind of binding. See concepts.md for the vocabulary used here.

Assignments

Assignments are the primary statement form. They define or update addressable bindings; a cell is the coordinate-addressed kind of binding. See concepts.md for the vocabulary used here.

This doc covers all assignment shapes. For rule blocks, see rules-and-schedules.md. For the language as a whole, see reference.md.


1. Anatomy

<target> IS <type-tag> <op> <expression>
<type-tag> <target> <op> <expression>
Part Required Notes
<target> yes A cell reference, named value, or finite range
<type-tag> no Semantic or native-kind contract, e.g. currency, List of String, or Tensor[?, 4]
<op> yes At top level, one of =, ~=, ?=. Compound operators are contextual: arithmetic in rule actions, colon-predicate add/remove (optionally with declared fields), and typed mutations such as table updates.
<expression> yes Any expression — see reference.md

Examples:

A1 = 10
A2 is currency = A1 * 100
currency A3 = A1 * 100
A3 ~= EXPENSIVE_FETCH()
A4 ?= MAYBE_FAIL()
A5 += :reviewed
A6:A10 = B1

When a model assigns the same target more than once at top level, the later assignment replaces the earlier one. Here answer is 23:

answer = 11
answer = 23

2. Assignment Operators

2.1 = (Eager)

The default. The cell recomputes every time any of its inputs change.

A1 = SUM(B1:B10)

When any of B1 through B10 change, A1 recomputes immediately.

2.2 ~= (Lazy)

The cell does not compute until something reads it. Useful for expensive or external work.

A2 ~= ML_SCORE([0.1, 0.2, 0.3, 0.4])

Reads happen when the cell is displayed, inspected, or referenced by another cell. A downstream eager cell that references A2 triggers A2 to compute.

Once computed, the value is cached. The cache invalidates when an input changes, returning the cell to the lazy state.

2.3 ?= (Conditional Eager)

Same as =, but if the right-hand side evaluates to an error, the assignment is skipped — the existing value (if any) is preserved.

A3 ?= MAYBE_FAIL()

If MAYBE_FAIL() returns #N/A, A3 keeps its previous value rather than becoming #N/A.

2.4 Compound Assignments

WHEN increment_requested THEN
  A4 += 5
  B4 -= 5
  C4 *= 2
  D4 /= 2
END

These work only on single-cell targets. The right-hand side is evaluated against the current value of the target.

Arithmetic compound assignment is valid only in rule actions, where the target has a committed value and the rule transaction supplies a real prior-value source. At top level, Grid rejects it with GRID_TOP_LEVEL_COMPOUND_ASSIGNMENT; use a reactive rule action for state changes. Typed mutation contexts such as table updates retain their documented operator meanings.

2.5 Graph handle declarations

graph is an exact named-handle declaration rather than a cell type. It uses plain = and a graph-producing expression:

graph roads = GRAPH(edges AS edges, "id" AS edge_id, "from" AS from, "to" AS to)
graph reversed = roads.REVERSE()

Graph handles cannot target cells or ranges and do not support decorators, mutation operators, lazy/conditional assignment, or schedules. See Graph Authoring for construction and query semantics.

2.6 Table mutations (type-dispatched)

When the assignment target is a table-typed named wire, compound operators mean record mutations against the model's embedded table store — not arithmetic. Grid applies them as record operations rather than binary expressions.

Operator Meaning on table symbol
+= Insert row(s)
^= Upsert (merge on row _id)
*= Update (patch by _id or filter)
-= Delete (by _id or filter)
table Orders += { name: "Acme", amount: 100 }
table Orders += COLLECT(inbound_frame)
table Orders ^= { id: "rec01HXYZ", status: "Shipped" }
table Orders *= { id: "rec01HXYZ", amount: 200 }
table Orders -= { id: "rec01HXYZ" }

Inside rule actions, cells and numeric named wires use += / -= / *= / /= for arithmetic read-modify-write. Ordinary top-level arithmetic compound assignment is rejected. ^= remains the table upsert operator in this typed mutation context; exponentiation remains an expression operator.

A standalone top-level table-mutation statement is an explicit host command: it commits once when submitted, rather than becoming a recalculating formula. The same four forms are valid inside WHEN/EVERY/AT bodies. There the RHS uses the rule's pre-commit snapshot and the durable table write runs after the rule's atomic cell-state commit.

A table's schema can carry write rules that every mutation must satisfy, such as the Ledger tables. On a table with a soft-delete column, -= marks the matching rows instead of removing them. A mutation that would change a fixed column, name a missing or archived referenced row, or leave a balanced group unequal is refused as a whole, and nothing is written.


3. Targets

3.1 Single Cell

A1 = 10
$A$1 = 10
Sheet1!A1 = 10
'Q4 Revenue'!B2 = 10
Revenue = 10                 /* named reference */

Workbook surfaces

Surfaces are namespaces that store component values with the same namespace specifier syntax as sheets (Namespace!Component).

Dataset_1!type = "dataset"

Dataset_1!config = <toml>
  version = 1
  kind = "dataset"
  title = "Dataset_1"
</toml>

Dataset_1!layout = ""
Dataset_1!data = ""

Use a language block for surface configuration. <toml>, <yaml>, and <json> produce structured values that the surface checks against its configuration schema. The block identifies its encoding; it does not choose the surface kind, which still comes from !type. Plain TOML strings remain supported for existing models. Use <jsx>…</jsx> for authored Component source.

The tab Dataset_1 appears when Dataset_1!type names a known surface kind. The frontend reads component values (especially !config) and renders the matching surface UI.

See ../product/surfaces.md for the surface families.

3.2 Named Address Aliases

A plain named assignment whose entire right-hand side is one absolute reference adds another address for the existing binding:

A1 = 125000
Revenue = A1
ReportedRevenue = Revenue

A1, Revenue, and ReportedRevenue address the same reactive binding. An external write or rule override through any of those names changes the value seen through all of them. They also share history and explanation identity; the aliases do not add calculation steps.

This rule applies only when the target is a name. Coordinate-to-coordinate assignment retains spreadsheet behavior:

B1 = A1                    /* a second cell whose formula reads A1 */
Tax = Revenue * tax_rate   /* a separate named calculation */

The direct-reference form is deliberately recognizable from source. It is an alias when all of the following are true:

  • the target is a named address;
  • the right-hand side is exactly one absolute cell or named reference;
  • the assignment is ordinary eager =;
  • it has no type contract, default, STATE, schedule, lazy mode, or conditional mode.

input and output only declare the external surface, so they may label an alias without creating another calculation:

output Revenue = A1
input Assumption = B2

When a name needs its own type, state, schedule, or error-preservation policy, Grid creates an independent named binding even if the expression is a single reference. Unary predicates remain properties of the authored name rather than flowing through the aliased value.

3.3 Finite Ranges

A range target broadcasts the right-hand side to each cell in the range. The range must be a finite rectangle.

A1:A10 = B1                  /* writes B1 to each of A1..A10 */
A1:C3 = 0                    /* writes 0 to all 9 cells */
A1:A5 = [10, 20, 30, 40, 50] /* writes the array elementwise */

Full-column (A:A) and full-row (1:1) targets are not allowed.

3.4 Destructuring

Bind multiple symbols from a single array-returning expression:

[total, avg, min, max] = SUMMARY(B1:B10)

Rest capture binds the tail:

[head, ...tail] = ARRAY_OF_VALUES

The number of named bindings before ... must not exceed the array length.


4. Type Tags

A type tag attaches semantic meaning to the value. The grammar is:

<target> IS [A | AN] <type-tag> = <expression>

Equivalently, prefix notation is accepted:

<type-tag> <target> = <expression>
A1 IS currency = 100000
A1 IS A currency = 100000
A1 IS AN fx_rate = 1.0825
currency A1 = 100000
currency x = 10
A2 is percentage = 21pct
A3 is date = d"2026-04-10"
A4 is bps = 25bps
A5 is score = ML_SCORE(features)
A6 is unit:m/s = distance / duration

Under strict, a value that cannot inhabit the tag's representation root is a #TYPE! — tagging is currency with a word-string errors, while a numeric string still coerces. loose (the default) applies the tag as an overlay without checking. This rides the coercion axis, so strict except coercion turns it off.

Tags form a hierarchy (USD is a currency is a number), and a leaf implies its ancestors — is USD needs no separate currency. See reference.md for the full tree.

Unit and currency tags are load-bearing in dimensional analysis. Physical units are built in (unit:m, unit:C, unit:delta_C) and currencies take the currency: qualifier (currency:USD, or bare USD); models may also declare scale-only custom units near the top of the source:

unit smoot = 1.7018 m
distance is unit:smoot = 2

Engineering conversions share the same unit vocabulary as physical tags. psi, bbl (petroleum barrel), darcy, mD/md (millidarcy), and cP (centipoise) are built in. Their names and explicit aliases are case-insensitive, as with other named units; SI prefixes retain their existing case-sensitive rules. Use m^2, m^3, and Pa*s for compound targets. Division after a power is supported (m^3/s), as are rational exponents (m^1/2/s). Shorthands m2, m3, and Pa.s are not unit expressions.

Unit Accepted aliases SI conversion
psi psi approximately 6894.757293168361 Pa
bbl petroleum_barrel, petroleum_barrels 0.158987294928 m^3
darcy darcies 9.869233e-13 m^2
mD md, millidarcy, millidarcies 9.869233e-16 m^2
cP centipoise 0.001 Pa*s

The factors follow NIST SP 811, Appendix B.8 and Appendix B.9. The barrel is exactly 42 U.S. liquid gallons; the darcy factor retains the published precision. Permeability has the dimension of area, but a unit tag alone does not prove that an area is a physically meaningful permeability.

# Convert a pressure difference; no atmospheric offset is implied.
pressure_drop_pa = CONVERT(1500, "psi", "Pa")
viscosity_pa_s = CONVERT(2, "cP", "Pa*s")
volume_rate_m3_s = CONVERT(1000, "bbl/day", "m^3/s")

psi is a pressure scale and does not select absolute or gauge pressure. State that convention in the model and supply an atmospheric reference when needed. bbl converts volume without selecting reservoir or standard conditions. Formation-volume factors and gas reference conditions remain explicit model assumptions. psia, psig, stb, rb, and scf are not silently treated as equivalent built-in units.

Custom declarations cannot shadow built-ins. If an older model declares one of these newly built-in names, remove an equivalent declaration or rename a different convention and all its references. Grid reports the conflict rather than silently changing the declaration's scale.

Native value kinds and optional contracts

Native structures use the same IS relationship. The shortest useful type is the kind itself:

items IS List = VECTOR(1, "two", {name: "three"})
pending IS Deque = DEQUE()
weights IS Tensor = A1:D1000

Those declarations do not demand homogeneous elements or a predeclared shape. The constructor, inferred expression, and resident runtime value establish the kind; an annotation merely adds a contract when the author wants one. Omitting the annotation entirely remains equally valid.

Parameterized contracts participate in Grid's coercion axis. strict checks their contents recursively, warn keeps the value and emits a coercion diagnostic, and the default loose mode does not scan collection contents. Bare kinds always remain open. Type nesting is bounded at 64 levels so hostile source cannot exhaust the compiler stack.

Use of to add an element contract and to for a keyed value contract. Types nest without switching to a punctuation-heavy mini-language:

names IS List of String = VECTOR("Ada", "Edsger")
jobs IS Deque of Job = DEQUE()
orders IS Map of Customer Id to List of Order = MAP_OF()
routes IS Trie of Route = TRIE()
latency IS Quantile Sketch = KLL()

List is the language name for Grid's persistent indexed sequence. The legacy spellings Vector<String> and Map<String, Vector<Order>> remain accepted, but grid fmt emits List of String and Map of String to List of Order.

Tensor follows the same progression. Shape, semantic element type, and precision are independent, optional contracts:

samples IS Tensor = A1:D1000
points IS Tensor[?, 3] = A1:C1000
distances IS Tensor[?] of unit:m = A1:A1000
weights IS Tensor[2, 2] with precision 32 = PARAMETER([[0.1, 0], [0, 0.1]])
score IS Tensor[] = AVERAGE(weights)

Omitted precision uses ordinary Grid Number semantics. Precision 32 is an explicit storage/computation policy; precision 64 is available when authors want to pin the default. Low-bit 2/4/8 storage is quantization, not floating precision, and is deliberately not disguised as a numeric type.

Tensor contracts are persisted independently from backend promotion. Shape, precision, and semantic element metadata therefore survive compilation even when a formula takes a general evaluation path; runtime publication restores the semantic element envelope without copying resident Tensor storage.

4.3 Dynamic cast inside expressions

Declaration-level IS attaches a tag to the assignment target. To tag a value inside an expression — for intermediate results or nested assignments — use infix INTO:

B1 = A1 * fx_rate INTO currency
C1 = ROUND(amount, 2) INTO currency

This lowers to TYPE_TAG(value, "<tag>") and applies the same semantic overlay as target IS <tag> = …. Use IS on the target when the whole cell carries the tag; use INTO when only part of the expression should be tagged.


4.1 Tags On Range Targets

The tag applies to every cell in the range:

A1:A10 is currency = 0

4.2 Tags Are Sticky

Once a cell carries a type tag, the tag persists across recomputations until a different assignment overwrites it (or removes it by omitting the tag).

A1 is currency = 100              /* A1 is currency */
A1 = A1 + 1                     /* A1 is still currency */
A1 is percentage = 0.5            /* A1 is now percentage */
A1 = "hello"                    /* A1 is now plain string (tag removed) */

4.3 Unary predicates

Where a type tag says what a value is, a predicate says what is true about the symbol. Colon predicates form an open, multi-valued set orthogonal to the type. Fieldless predicates are ad hoc and need no declaration. Use predicate to declare a reusable typed field schema shared by unary attachments and explicit model-graph relationship payloads:

predicate reviewed(by: string, score: number)

x is :happy USD = 1.00            /* type USD, plus predicate :happy */
x is a :draft :urgent = 5         /* several predicates, no type */
x is a :reviewed(reviewer, 0.95) USD = 1.00
reviewer1 = PREDICATES(x).reviewed.by
reviewer2 = x.predicates.reviewed.by  /* equivalent */

The a or an after declaration-side is is optional surface sugar; the leading colon identifies a predicate. A declaration is single-valued by default, with an optional explicit one designation. A multi-valued predicate declares the field tuple that distinguishes its instances:

predicate reviewed(id: symbol, by: symbol, at: datetime) many by (id)

x is :reviewed(:r1, :ada, NOW()) :reviewed(:r2, :lin, NOW()) = report
review_ids = PREDICATES(x).reviewed.id  /* [:r1, :r2] */
reviewers = x.predicates.reviewed.by    /* [:ada, :lin] */

The declared field count and types apply wherever :reviewed(...) is attached. An undeclared predicate may not carry fields. Field access is schema-checked; PREDICATES(x).reviewed and x.predicates.reviewed query Boolean presence, while a field of an absent predicate is BLANK. A many-valued field returns an array in stable first-insertion order. Declared field types fail closed with #TYPE! even in loose mode. For a single predicate, += :reviewed(...) replaces its atomic state and bare -= :reviewed removes it. For many, += supplies the full payload and upserts by the declared identity; -= :reviewed(key, ...) supplies keys in many by order and removes that instance; bare -= :reviewed removes all instances. These mutations are reactive inside WHEN, EVERY, and AT rules; a predicate introduced only by a rule starts absent until an add action fires. Unary predicates describe the symbol, not the value, so they don't flow through arithmetic. See the predicate guide and reference.md.

Reverse lookup is available through the unified reactive Graph receiver MODEL.PREDICATES:

all_reviewed = MODEL.PREDICATES.NEIGHBORS(:reviewed)
predicates_on_x = MODEL.PREDICATES.IN_NEIGHBORS("x")

Within this view, colon symbols are predicate vertices and quoted canonical names are model-symbol vertices. The colon therefore prevents :reviewed from colliding with a model symbol named reviewed. Each directed edge has kind :applies; structured fields live on that edge. Authored binary Predicate facts occupy the same view. A many unary predicate has one parallel edge per identity, though NEIGHBORS still returns each target symbol once. Rule mutations revise the view automatically. It is deliberately separate from MODEL.GRAPH, so unary-predicate membership does not change model dependency or dependency algorithms. The carrier-owned membership ID is an opaque versioned string, currently tag-membership:v2:<sha256>, and stays stable when an upsert changes payload fields without changing its typed identity. TAGS(x), x.tags, and the unary-only MODEL.TAGS view remain accepted for source compatibility; new source should use the Predicate spellings above.


5. Ownership, State, And Model Boundaries

Every writable binding has two conceptual value layers:

  • The base is the value produced by its top-level declaration.
  • A sticky override is a committed write from an admitted external writer or a rule action. While present, it wins over the base.

The value authors read is the effective value: the override when one is present, otherwise the base. RESET removes an override and reveals the base; CLEAR installs a blank override. Aliases share these layers because they are additional addresses for the same binding.

Declarations state who is supposed to write:

Form Intended owner Base value Other writers
A1 = expr formula expr none; a rule set earns GRID_MIXED_OWNERSHIP
input A1 = expr external caller expr admitted external writes
default A1 = expr formula plus override layer expr rules; external writes when also declared input
STATE A1 = expr model rules initial/reset value expr rule actions
rule-only target model rules BLANK rule actions

output does not grant write authority. It publishes a binding on the model's read surface.

Two optional decorator keywords may appear at the start of an assignment:

input  <target>[IS <tag>] = <expression>
output <target>[IS <tag>] <op> <expression>

They label bindings as part of the model's external write surface (input) and read surface (output).

5.1 input Decorator

input marks a binding as externally writable.

input price = 0
input qty   IS integer = 0
input items = []

Restrictions — input may only appear on a plain eager = assignment with a default value. The parser rejects:

Form Why
input A1 ~= … Lazy bindings have no initial value to act as a default
input A1 ?= … Conditional bindings skip on error — undefined initial state
input A1 += 1 (and -=, *=, /=) Arithmetic compound assignment belongs in a rule action, not a declaration
input A1 = … ONCE (and EVERY, AT) Scheduled bindings are driven by time, not external writes
Inside WHEN … THEN … END rule actions Rule actions are model-owned; declare input on the top-level binding instead

The default value (the right-hand side of =) is the value the binding holds before any external write arrives. It can be any expression — including a function call — as long as it does not depend on other declared inputs that have no default of their own.

5.2 output Decorator

output marks a binding as externally readable.

output total       = price * qty
output history     ~= EXPENSIVE_AGGREGATE(transactions)
output last_seen   = NOW() ONCE
output default running_log = []

output may decorate top-level single-cell and named assignments. Rule actions do not declare model boundaries; declare the target as an output at the top level, or publish a semantic alias for it. Use outputs deliberately to define what the model promises to consumers.

5.3 Combining Decorators

input and output may appear together, in either order:

input output A1 = 0            /* writable input that also surfaces as a read */
output input A1 = 0            /* equivalent */

Each keyword may appear at most once per assignment. Decorators are case-insensitive (input, INPUT, Input all parse the same).

5.4 default Decorator

default marks a formula as an overridable base value. The formula computes normally, but a rule action (or an external input) may install an override on top; the override wins and is sticky (it persists until cleared), and RESET restores the base.

default price = model_estimate     /* computed default, may be overridden */
WHEN incident THEN price = manual_number END   /* override wins, sticky */
WHEN resolved THEN RESET price          END    /* base governs again */

Without default, a cell is owned by either its formula or its rule actions, never both: a rule that sets a plain formula cell shadows it silently and earns the GRID_MIXED_OWNERSHIP warning (see errors.md). default is the opt-in that makes the override intentional and inspectable — WHY reports the value as overridden rather than silently tracing the shadowed formula.

default applies to single-cell and named formula targets. It is not supported on range or table-mutation targets. It composes with type tags (default B1 IS currency = …). Like input/output, it is case-insensitive and appears at the start of the assignment.

It may compose with input when both external callers and model rules are intentional writers:

input default price = model_estimate

5.5 STATE Declaration

STATE declares model-owned mutable state. Its right-hand side is the initial value and the value restored by RESET; rule actions install sticky revisions on top of it.

STATE retry_count = 0

WHEN request_failed THEN retry_count += 1 END
WHEN request_succeeded THEN RESET retry_count END

output retries = retry_count

STATE is distinct from an ordinary formula. A formula describes what a value is now from its dependencies; state remembers what prior rule waves did. It is also distinct from input: rules own state, while callers own inputs.

A state declaration uses a single-cell or named target with eager =. It cannot itself be decorated input or output. Publish state through an output alias or a derived output, as above. STATE uses the same preserved base and override machinery as default, but keeps its authored state identity for formatting and tooling.

5.6 Declared Surface

Use decorators to make the model boundary explicit:

  • Inputs are the cells a caller or collaborator is expected to change.
  • Outputs are the cells a caller or collaborator is expected to read.
  • Any referenced but undeclared cell is just an implementation detail of the model.

Even when a workspace does not enforce the boundary, the declarations remain valuable documentation for people and tools.

5.7 Common Mistakes

input A1 += 1     /* rejected: input requires plain `=` with a default */
input A1 ~= 0     /* rejected: input requires plain `=` with a default */
A1 = input        /* rejected: `input` is a reserved word */
input input A1=0  /* rejected: duplicate `input` decorator */
STATE input A1=0  /* rejected: state is model-owned, not an external input */

To attach input to a counter you intend to increment from rules, declare the input separately:

input default counter = 0        /* external surface plus intentional rule writes */
WHEN trigger > 0 THEN
  counter += 1
END
output current_counter = counter  /* publish the binding at top level */

6. Schedule Modifiers

A single-action assignment can carry a schedule modifier. This is equivalent to wrapping the assignment in a single-action rule block.

G1 = NOW() EVERY cron"0 * * * *" BACKFILL
G2 = TRUE AT dt"2026-12-31T23:59:00Z"
G3 = NOW() ONCE

Schedule modifiers are sugar for:

EVERY cron"0 * * * *" BACKFILL THEN
  G1 = NOW()
END

AT dt"2026-12-31T23:59:00Z" SKIP MISSED THEN
  G2 = TRUE
END

Three forms are accepted:

Form Behavior
<expr> EVERY <duration|cron> [SKIP MISSED|BACKFILL] Wraps as an EVERY rule block
<expr> AT <datetime> [SKIP MISSED|BACKFILL] Wraps as an AT rule block
<expr> ONCE Capture the first successful evaluation and do not recompute it. Useful for snapshot values such as B1 = NOW() ONCE. An unchanged assignment keeps its captured value when the same resident model is replaced; changing the expression starts a new capture.

6.1 Restrictions

Schedule modifiers attach only to eager = assignments. Grid rejects:

  • ~= lazy assignments
  • ?= conditional eager assignments
  • += / -= / *= / /= compound assignments

A model that combines any schedule modifier with ~=, ?=, or a compound operator builds with GRID_ASSIGNMENT_SCHEDULE_OPERATOR. The diagnostic names the attempted scheduled form, explains why schedules require eager writes, identifies an eager top-level assignment as the valid context, and shows an = rewrite. See contextual grammar diagnostics.

6.2 Semantics

The canonical style prefers explicit rule blocks for shared models; modifiers are a convenience for short single-action cases. An inline EVERY or AT target exists as BLANK before its first firing. Its RHS is evaluated only by the scheduler, not as an ordinary base formula.

6.3 ONCE semantics

ONCE cells follow these rules:

  1. The expression evaluates on the first evaluation wave that includes the cell (normally model application or its first read).
  2. After the first successful evaluation, invalidations from upstream inputs or calculations do not trigger recomputation. The cell stays at its captured value.
  3. Same-id replacement or runtime-host restart preserves the prior ONCE value if the cell's expression is unchanged. If the expression changes, the cell recomputes once with the new expression.
  4. If the first attempt errors (e.g. #DIV/0!), the result is not sticky — the cell retries on the next invalidation wave.

7. Nested External Calls

When an external call is nested inside a larger formula, Grid tracks that call separately so the fetched value can be cached and retried independently:

A1 = ROUND(FX_RATE("EUR", "USD") + 0.01, 4)

For readability, prefer naming the external boundary when it is reused:

eur_usd = FX_RATE("EUR", "USD") DEFAULT 1.08
A1 = ROUND(eur_usd + 0.01, 4)

8. Restrictions Summary

Constraint Where it applies
Range targets must be finite rectangles Top-level and rule-action assignments
Arithmetic compound += / -= / *= / /= is valid only on a single-cell or named target inside a rule action Rule action body
Value-first <value> AS <parameter> binds call arguments by name; it is not assignment All contexts
Inside rule actions, =, ?=, and compound (+=/-=/*=//=) are allowed; ~= and nested rules are rejected Rule action body
Rule action RHS may not contain external async calls because rule actions must commit atomically with the rule wave Rule action body
Namespace specifiers use single quotes for spaces; identifiers don't need them All contexts
input decorator requires a plain = assignment with a default value; rejected on ~=, ?=, compound, scheduled, and rule-action targets All contexts
VALIDATE FIELD requires a uniquely owned local eager, unconditional, unscheduled input; simple aliases share its contract Standalone field declaration; see record field rules
output decorates top-level single-cell or named assignments; rule actions do not declare the model boundary Top level
STATE uses an eager = single-cell or named declaration and cannot also be input or output Top level
Each decorator (input, output) may appear at most once per assignment All contexts

9. Common Mistakes

9.1 Missing Operator

A1 10            /* parse error */
A1 = 10          /* correct */

9.2 Single-Quoted String

A1 = 'hello'     /* 'hello' is parsed as a namespace specifier, not a string */
A1 = "hello"     /* correct */

9.3 Range Target Too Large

A:A = 0          /* rejected — full-column ranges not allowed as targets */
A1:A1000 = 0    /* works, but explodes the dependency graph */
A1:A1000 ~= 0   /* ~= not allowed in range target broadcasting */

Use an anchor assignment with an array-valued RHS instead. For constant fills the dedicated helpers are clearest:

A1 = ZEROS(1000, 1)         /* column of zeros */
A1 = ONES(1000, 1)          /* column of ones */
A1 = FILL("TBD", 1000, 1)   /* column of any constant value */
A1 = REPEAT(0, 1000)        /* equivalent shorthand for column of zeros */

When each output element depends on its row/column index, prefer an array comprehension — it lowers to a single MAKEARRAY binding but reads more naturally:

A1 = [r * 2 FOR r IN 1..1000]
A1 = [r * c FOR r IN 1..100, c IN 1..10]

Reach for MAKEARRAY directly when you need three or more index variables, want to share heavy intermediates via LET, or are computing the dimensions dynamically. For constant fills, prefer FILL / ZEROS / ONES / REPEAT.

9.4 Incompatible Type Tag

A1 is currency = "hello"      /* #TYPE! — string can't be currency */
A1 is currency = "100"        /* OK — string coerces to number */
A1 is currency = 100          /* canonical */

9.5 Cycle

A1 = A2 + 1
A2 = A1 + 1     /* both cells become #CIRCULAR_REF! */

Cycles are detected and reported as #CIRCULAR_REF! unless iterative calculation is enabled for a convergent cycle. Lazy bindings (~=) participate in cycle detection.


10. Examples

A complete, well-typed model:

MODEL "Pricing"
VERSION "1.0.0"

# Declared inputs (writable surface in protected mode)
input  A1 is currency   = 100000    /* base price */
input  A2 is percentage = 7pct      /* discount */
input  A3 is percentage = 21pct     /* tax rate */

# Declared outputs (readable surface in protected mode)
output B1 is currency = A1 * (1 - A2)      /* discounted price */
output B2 is currency = B1 * (1 + A3)      /* gross price */

# Lazy expensive lookup, exposed as output
output C1 ~= FX_RATE("USD", "EUR")

# Conditional output (only if FX succeeds)
output D1 is currency ?= B2 * C1

# Internal counter (not part of the external surface)
default E1 = 0
WHEN increment_requested THEN E1 += 1 END

# Range broadcast (every cell carries the `output` decorator)
output F1:F5 = 0

# Destructuring (decorator broadcasts to each binding)
output [min, max, avg] = SUMMARY([100, 200, 300, 400])

END MODEL

This model is safe to deploy with protectedMode: true: every implicit input is declared (the model has no implicit inputs), and the model declares both an input and an output.


11. SOLVE Statements

A SOLVE statement declares a goal-seek or bounded optimization over workbook cells. The result cell receives the value of the variable cell that satisfies the goal; the variable cell itself is never mutated, so the sheet stays consistent and auditable.

SOLVE Z9 = A2 IN [0, 1] GOAL A3 = 200000
SOLVE B9 = A1 IN [0, 5] MINIMIZE A2
SOLVE B9 = A1 IN [0, 5] MAXIMIZE A2
SOLVE Recommended = Candidate IN [Minimum, Capacity] GOAL Residual = Target
  • Z9 — result cell (receives the solved variable value).
  • A2 — variable cell the solver varies inside the bounds [lo, hi] (fractional bounds are fine).
  • GOAL A3 = 200000 drives cell A3 to the target by root finding; MINIMIZE / MAXIMIZE optimize the objective cell instead.
  • The goal may reach the variable through intermediate cells (chains and diamonds compose by the chain rule); other referenced cells participate as data parameters at their current values.
  • Result, variable, and objective can also be named wires. Bounds and goal targets can be expressions, evaluated once at the current input values before solving. They must be finite numbers with ordered bounds.
  • Solves are reactive: editing an upstream input re-solves on the next resolve.

Current limits: one variable per statement, default-sheet addresses, and expression-backed chains (a chain crossing a descriptor-compacted range refuses). A statement the compiler cannot lower reports #CALC! (SOLVE_NOT_COMPILED); a solve that fails at runtime (no root in the bounds, non-smooth objective) reports #CALC! (SOLVE_FAILED) on the result cell without failing the rest of the model.

Solves execute on the solver-region lane and write back unconditionally — the result cell has no other evaluator. The resolve response carries a structured solverWrites report (cell, values, boundDuals), where boundDuals is the goal's marginal at the solution (∂goal/∂variable) — the shadow-price view of the solve; the same values appear in the solve's diagnostics.


12. Named and Indexed Optimization

name = SOLVE … END binds an optimization result, with local unknowns and linear constraints. SOLVE name … END is an equivalent alternative spelling. Both produce the same owning AST and one existing LP/MIP report call; reactive inputs and the result run through the ordinary compiled evaluator. Source formatting preserves the chosen spelling; source-free canonical rendering uses assignment.

These notation bindings currently require a plain, unqualified top-level name and =. Cell/range targets, decorators, other assignment operators and nested blocks are not supported. Ordinary references and function calls named plan or solve retain their expression grammar.

input capacity = 4

allocation = SOLVE
  VARIABLE x IN [0, 2]
  VARIABLE y IN [0, 3]
  REQUIRE total_capacity: x + y <= capacity
  MAXIMIZE 3*x + 2*y
END

chosen_x = allocation.values.x
objective = allocation.objective
status = allocation.status

This model returns approximately x = 2, y = 2, and objective 10. Changing capacity re-solves the model without mutating either unknown into an outer input. The earlier single-variable SOLVE result = … form is unchanged.

  • VARIABLE name [IS continuous|integer|binary] IN [lower, upper] declares an unknown. Continuous is the default. Binary bounds may be omitted for [0,1]. Bounds must be finite and satisfy lower <= upper, including negative and fixed bounds. Binary bounds must stay within [0, 1]. Unbounded/free unknowns are outside this version.
  • REQUIRE label: left <=|>=|= right declares a named constraint. Equality becomes two labeled inequalities. Labels and unknown names are unique within their respective namespaces, compared without case.
  • Exactly one MINIMIZE or MAXIMIZE gives the objective. Unknown-bearing expressions allow addition, subtraction, multiplication by a parameter, and division by a parameter. Objectives additionally admit the convex ABS/MAX penalties described below. Products of unknowns and other nonlinear functions of unknowns are rejected.
  • Parameters are previously declared, untagged numeric inputs or pure numeric formulas. A bounded numeric function subset includes ABS, MIN, MAX, SUM, PRODUCT, roots, logarithms, exponentials, rounding, and trigonometry. User callables, effectful/volatile calls, tagged quantities, and arbitrary record access and arbitrary array operations are not admitted. Fixed-shape numeric arrays support the checked INDEX reads described below. Parameter expressions use lexical snapshots; invalid or nonfinite inputs produce a value error before solver backend execution.

Use finite domains to name families of unknowns and constraints:

workers = [:ana, :ben]
tasks = [:cut, :pack]

assignment = SOLVE
  VARIABLE assigned[task IN tasks, worker IN workers] IS binary
  REQUIRE covered[task IN tasks]: SUM(worker IN workers, assigned[task, worker]) = 1
  REQUIRE available[worker IN workers]: SUM(task IN tasks, assigned[task, worker]) <= 1
  MINIMIZE 3*assigned[:cut, :ana] + 5*assigned[:cut, :ben]
         + 4*assigned[:pack, :ana] + 2*assigned[:pack, :ben]
END

Domains are preceding constant row or column arrays, including constant aliases. Keys must be unique, nonempty text, symbols, or exact integers, with one key representation per domain. Input-dependent domains and Frames are rejected; domain changes require recompilation. Nested binders cannot shadow active indices, unknowns, or declared parameters. An index tuple is meaningful on a SOLVE unknown or declared parameter. Declaration and domain order determine columns; the last index varies fastest.

Newly compiled blocks retain the wrapper schema grid.named-optimization.v1:

Field Meaning
status Exact status from the existing optimization report.
values Record keyed by unknown name; indexed unknowns are arrays in domain order. Blank when no candidate exists.
axes Ordered domain arrays for each indexed unknown; an empty record for scalar-only models.
objective Candidate objective including the variable-independent constant; blank without a candidate.
report grid.optimization.report.v2 sparse report, including variable/row labels and solver evidence. Its objective excludes the constant.

assignment.values.assigned is a task-by-worker matrix and assignment.axes.assigned carries those two ordered domains. Reading fields does not launch additional solves. Budget-limited candidates retain their actual status; a candidate does not by itself establish feasibility or optimality. Authored bounds are native column bounds, exposed as each variable's lower_bound and upper_bound. They do not add constraint rows. For example, VARIABLE balance IN [-5, 3] allows a negative balance directly. Fixed variables such as VARIABLE reserve IN [-2, -2] are supported.

The report's constraint_coefficients is a grid.sparse-matrix.csr.v1 record: row_count, column_count, row_offsets, column_indices, and coefficients. Offsets and column indices are zero-based; vectors are row arrays. Consecutive offsets delimit each row's sorted coefficient entries. Empty rows repeat their offset; a model with no encoded constraints has offsets [0]. The compiler and report never materialize a dense constraint matrix. >= constraints retain the negated encoding and equality retains its two labeled opposite inequalities. The original positional LP/MIP report functions still return dense v1 reports. The named wrapper and its objective-constant convention are unchanged.

Fixed-shape parameter arrays

Declare a local PARAMETER to read array values by domain keys:

products = [:chair, :desk]
input unit_profit = [3, 2]
input production_limit = [2, 3]

allocation = SOLVE
  PARAMETER profit[product IN products] = unit_profit
  PARAMETER limit[product IN products] = production_limit

  VARIABLE quantity[product IN products] IN [0, limit[product]]
  REQUIRE capacity: SUM(product IN products, quantity[product]) <= 4
  MAXIMIZE SUM(product IN products, profit[product] * quantity[product])
END

A parameter is a read-only local view of data. It does not introduce an unknown, global assignment, or result field. One axis maps an exact-length row or column vector; two axes map rows then columns. Domain order determines storage order: with keys [20, 10], profit[10] reads the second value. Keys may be symbols, text, or exact integers, using the same rules as unknown domains. Reads need the declared number of keys, supplied as literals or active index binders.

Values remain reactive; axes and dimensions require recompilation to change. Initializers and domains read the outer scope before local declarations become visible, so PARAMETER costs[k IN products] = costs can name a view of an outer array. Local parameter and unknown names must be unique without regard to case. Parameter-to-parameter initializers, scalar parameters, more than two axes, dynamic domains, and keys outside a domain are refused. Each declaration retains the complete array contract even when no term reads it or terms cancel. Static domain and index constants must have one definition and no reactive rule writes, including through aliases. Local unknowns and parameters cannot shadow those constants. This keeps compiled labels and positions consistent with the module; parameter array values may still be updated by inputs or rules.

The named parameter example includes usage[resource, product] for a coefficient matrix and capacity[resource] for right-hand sides. These accesses lower to fixed coordinates in the same checked array path as explicit INDEX calls below. The transportation example uses two indexed shipment dimensions, stock constraints and exact order fulfilment. Changing costs or stock re-solves the allocation; insufficient total stock produces the native infeasible result rather than a partial shipment plan.

Use INDEX(data, row, column) for scalar coefficients, right-hand sides, and variable bounds. All three arguments are required; positions are one-based compile-time positive integers, including expanded numeric binders and constant arithmetic with +, -, *, and /. The array can be a rectangular literal or a preceding binding/alias whose initializer has a known rectangular shape. Its elements must be pure numeric expressions. Inputs may replace values without recompiling.

products = [1, 2]
input unit_profit = [3, 2]
input production_limit = [2, 3]

allocation = SOLVE
  VARIABLE quantity[product IN products] IN [0, INDEX(production_limit, 1, product)]
  REQUIRE capacity: SUM(product IN products, quantity[product]) <= 4
  MAXIMIZE SUM(product IN products, INDEX(unit_profit, 1, product) * quantity[product])
END

Row vectors, column vectors, and two-dimensional arrays use the same explicit row/column convention. The complete example includes indexed resource rows and a coefficient matrix. Values and signed bounds remain reactive through ordinary reactive dependencies. Domains, dimensions, and positions stay fixed; change them by recompiling the source.

The selected report adapter validates every supplied array, including unread cells, before solving. Shape changes, ragged arrays, nested arrays, blanks, booleans, text, tagged quantities, and nonfinite elements return #VALUE!; a subsequent valid update can solve again. Array-valued coefficients, dynamic index positions, array-producing functions, Frames, and record lookups are not supported by this profile. Each SOLVE admits at most 1,000 parameter arrays and 1,000,000 total declared cells, subject to existing compiler work budgets.

The compiler uses the single-record overload LINEAR_PROGRAM.REPORT(model) or MIXED_INTEGER_PROGRAM.REPORT(model). Its model schema is grid.linear-program.sparse.v1, with numeric vectors objective, row_offsets, column_indices, coefficients, rhs, lower_bounds and upper_bounds; boolean maximize; and label vectors variable_labels, row_labels and original_senses (<= or >=). MIP also requires a numeric integer_mask of zeros and ones; the continuous overload refuses a nonblank mask. Coefficients encode A x <= rhs; original_senses is presentation metadata for negated >= rows, not an instruction to change the solved inequality. Missing variable or row labels may be blank to generate defaults. CSR rows require sorted unique zero-based columns; coefficients that evaluate to zero are omitted from the native model. The same finite-value, bound-order and size checks apply to direct calls. The adapter permits up to 20,000 encoded rows because each of the 10,000 authored equality constraints may contribute two rows. Array-backed models select grid.linear-program.sparse.v2, which requires a nonempty parameter_arrays row/column array of records with name, values, rows, and columns. values must match the declared positive dimensions and contain only finite untagged numbers. The ingress version makes older runtimes refuse rather than ignore array validation. Scalar-only models retain sparse v1 ingress; both versions produce the existing sparse v2 report and named v1 wrapper.

Integer/binary unknowns require a solver-mip runtime build. Missing capability produces the existing invalid_problem report with an integer-backend capability warning, without relaxing integrality.

Expansion is limited to 1,000 total variable columns and 10,000 constraint instances, eight indices per declaration, and 1,000,000 stored constraint coefficients, including both equality rows. Bounds do not count as constraint coefficients. Expression expansion and native compiler/solver budgets also apply. Indexed report labels must fit 480 UTF-8 bytes. Sparse coefficient arrays and native bounds feed the existing solver model.

The V3 owning syntax retains declarations, bounds, objectives, index domains, and constraints. Canonical admission constructs native expressions directly; it does not generate and reparse Grid source.

Owning transport, formatting, free-name inspection, and checked expression edits use that same admission path. Local parameter names support checked renames, including their reads; they do not define public result keys. Unknown/index declaration renames refuse in this first version: unknown names also define public result fields and report labels, and index declarations need distinct rename identities. Edit a numeric coefficient, bound, or constraint expression through the checked AST workflow.

Keyed parameter inputs

Use BY ... VALUE ... when source rows carry their own keys. Grid aligns them to the declared axes once, before solving:

products = ["alpha", "beta"]
input forecasts = {
  product: ["beta", "alpha"],
  demand: [4, 6],
  margin: [2, 3]
}
allocate = SOLVE(rows, capacity)
  PARAMETER demand[p IN products] = rows BY product VALUE demand
  PARAMETER margin[p IN products] = rows BY product VALUE margin
  VARIABLE quantity[p IN products] IN [0, demand[p]]
  REQUIRE capacity_limit: SUM(p IN products, quantity[p]) <= capacity
  MAXIMIZE SUM(p IN products, margin[p] * quantity[p])
END
allocation = allocate(forecasts, 8)

This returns quantity = [6, 2] in product order, with objective 22. Changing source row order does not change the result. The alternative SOLVE allocate(rows, capacity) header has the same meaning. Ordinary local scope, named arguments, partial application, imports and reusable calls also apply.

Two axes use two key columns in the same order as the axes:

PARAMETER cost[o IN origins, d IN destinations] =
  routes BY origin, destination VALUE cost

The first axis becomes matrix rows; the second becomes columns. The selectors following BY and VALUE are literal column identifiers, so renaming a Grid variable does not rename them. Axis domains remain fixed at compilation, just as for positional parameters. Incoming values and row order may change.

The ordinary function exposes the same admission operation:

demand = KEYED.VALUES(forecasts, ["product"], [products], "demand")
inputs = KEYED.VALUES(forecasts, ["product"], [products], ["demand", "margin"])

A single value-column string returns a numeric row or matrix. A vector of value column names returns a record containing those arrays, sharing one selected read and alignment. Within one SOLVE invocation, parameters using the same source binding, key columns and domains share that operation automatically. Independent source expressions are evaluated independently.

Sources may be column records, matrices with a header row, native vectors of row records, declared Tables, already materialized model-local Frames, or resident graph-query Frames in the current resolve. Only selected key and value columns participate. Physical Int64 Frame columns admit only lossless safe integers; semantic tags remain intact and tagged numerical coefficients refuse. Declared Table mutations invalidate dependent calls; explicit Table handle inputs must be replaced with the current handle after a mutation. Frames retain their immutable snapshot and must be replaced explicitly. Resident graph-query references must match the current Frame revision; references captured before a graph mutation are rejected. Admission never runs lazy ETL or creates a job.

Every domain tuple must appear exactly once. Missing, duplicate, outside-domain or wrong-kind keys fail, even if repeated rows contain identical values. Keys are nonempty text, symbols or exact safe integers; text is case-sensitive and these three kinds stay distinct. Values must be finite untagged numbers: blank, text, boolean, nested, nonfinite and tagged values fail, including in unused parameters. No rows are dropped, filled or aggregated implicitly.

Admission permits at most 100,000 rows/tuples, 1,000,000 selected cells, 128-byte keys, two axes and 1,024 value columns. Resident Table projections additionally have a 64 MiB selected snapshot budget; Frame IPC snapshots must fit within 64 MiB, including unselected columns. Products and carrier bounds are checked before output allocation; existing tighter solver limits still apply. Sparse coverage, implicit unit conversion and inferred axes are outside this first profile. See the complete example.

Soft constraints and convex piecewise-linear objectives

Keep compulsory requirements in REQUIRE. Express a preference by charging for its violation in the objective:

input demand = 8
input capacity = 6
input unit_cost = 2
input shortage_penalty = 10

allocation = SOLVE
  VARIABLE production IN [0, capacity]
  MINIMIZE unit_cost * production
    + shortage_penalty * MAX(0, demand - production)
END

The model produces six units and pays for a two-unit shortage. Making production >= demand a REQUIRE instead would make this example infeasible. There is no automatic relaxation of hard constraints.

Preference Penalty expression
Meet or exceed a target weight * MAX(0, target - actual)
Stay below a preferred ceiling weight * MAX(0, actual - ceiling)
Stay close to a target weight * ABS(actual - target)
Minimize the worst of several affine costs MAX(cost_a, cost_b, cost_c)

ABS accepts an affine decision expression. MAX accepts scalar affine expressions and nonnegative weighted sums of admitted penalties; for example, MAX(2 * ABS(x - 1), ABS(x + 1)). Binder SUM supports indexed penalty terms and named parameter data. Bounds, target values, affine coefficients and penalty weights retain the existing reactive numeric parameter contract.

MINIMIZE may add nonnegative weighted penalties; MAXIMIZE may subtract them. A zero weight is valid. Negative weights and nonconvex nesting are refused; a reactive weight that changes to an invalid sign returns #VALUE! until corrected. ABS(MAX(x, 0)), products of unknown expressions and general nonlinear objectives remain outside this profile. ABS/MAX over parameters alone keep their ordinary numeric meaning.

The compiler introduces continuous helper variables and linear constraints, then calls the existing LP or MIP report function once. Helpers share the 1,000-column, 10,000-constraint and 1,000,000-stored-coefficient ceilings with authored declarations. Their conservative finite bounds are derived from the authored bounds at each resolve; overflowing helper-bound arithmetic refuses the resolve. Integer authored decisions still require the MIP capability; helper variables remain continuous.

values and axes contain only authored unknowns. Diagnostic report.variables and report.constraints also include reserved @penalty[...] labels for the lowered formulation. The named objective evaluates the authored objective at the candidate decisions. report.objective describes the encoded linear objective and excludes its constant; it can additionally differ when a budget-limited candidate has loose helper variables. Neither a candidate nor its evaluated cost establishes feasibility or optimality: always inspect status. Source-free owning artifacts retain the authored ABS/MAX expressions, not generated helper declarations.

Lexicographic objectives

Use THEN to prioritize goals. Each later stage optimizes within protection bounds established by completed earlier stages:

allocation = SOLVE
  VARIABLE x IN [0, 2]
  VARIABLE y IN [0, 3]
  REQUIRE demand: x + y >= 4

  MINIMIZE MAX(0, 3-y) TOLERANCE ABSOLUTE 0.001 RELATIVE 0
    THEN MINIMIZE x + 5*y
  BUDGET 1000 ITERATIONS
END

THEN expression inherits the preceding direction; THEN MINIMIZE expression and THEN MAXIMIZE expression select it explicitly. Two through sixteen objectives support indexed affine terms and the existing convex ABS/MAX forms. Both assignment and declaration-first headers work. All stages share the existing expanded-model limits, including helper columns and protection rows. Hard requirements and variable bounds retain their meanings.

Optional TOLERANCE ABSOLUTE a RELATIVE r belongs to the preceding objective. Omitted tolerances are zero. Values may be reactive pure numeric parameters, must be finite and nonnegative, and cannot depend on unknowns. For an achieved objective value z, later stages permit degradation a + r * abs(z). The constant offset is included in z. Minimization adds the allowance; maximization subtracts it. Zero means no requested degradation; native numerical feasibility and optimality tolerances still apply separately and are reported. Overflow refuses rather than removing the protection bound. Triangular epigraph helpers are tightened to their admitted affine maxima before measuring an achieved objective, so helper slack cannot enlarge a relative allowance.

BUDGET n ITERATIONS is available with multiple objectives. It reserves a shared integer effort allowance (0–1,000,000,000), capped by the host's existing iteration/evaluation limits. Each stage receives the remaining allowance divided by the number of remaining stages, rounding up. Reservations are not measured work and are not refunded. The allowance limits native LP iterations and, for HiGHS, MIP nodes/leaves separately; it is not a combined instruction count. Presolve, wall time and peak memory are not bounded by this clause. Omission uses the host caps; zero performs no backend solve and reports budget_exhausted.

Only native convergence at the requested solver accuracy, followed by an independent finite, bound, row and integrality check, permits advancing. A reduced-accuracy result retains its feasible candidate but cannot certify a priority. No lower objective runs after an unfinished stage. A prior feasible candidate may survive a later failure, but the overall status remains unfinished. A failed later protected stage never labels the original model infeasible. Cancellation is checked at native stage boundaries. Integer models still require solver-mip.

Multiple objectives select grid.named-optimization.v2. values and axes retain their meanings. objective evaluates the first authored objective at the final candidate; objectives is a row array evaluating every authored objective there. Missing candidates yield blank objective values. Generated helpers never substitute for authored ABS/MAX evaluation in these public values.

The versioned grid.optimization.lexicographic-report.v1 report adds:

  • completed_stages: the completed prefix length; candidate_stage: the one-based stage supplying the retained candidate.
  • stages: status (not_started when unattempted), completion, direction, achieved optimum, degradation tolerances and reserved allowance.
  • reserved_iterations and budget_note: explicit effort accounting.

Its sparse model describes the last attempted stage, including @priority[...] protection rows. Its scalar objective and marginals concern that stage; they are not joint multi-objective sensitivity. Incomplete results omit dual claims. Older runtimes reject the new formulation schema instead of ignoring priorities. Single-objective payloads and result schemas remain unchanged.

13. Planning declarations

fabrication = PLAN … END defines a native scheduling problem and binds a model-local reference to it. PLAN fabrication … END is an equivalent alternative, including the same reference binding. The existing Planning operation solves it and publishes requested outputs. Targeted reads of the reference or its aliases do not run scheduling search or publish results. Explicit whole-module compiled execution still runs its Planning regions, as before.

fabrication = PLAN TICK 1min
  RESOURCE laser CAPACITY 1
  RESOURCE welder CAPACITY 2
  CALENDAR laser = [[0min, 8h], [9h, 17h]]
  ACTIVITY cut DURATION 4h USING laser
  ACTIVITY assemble DURATION 5h USING welder AFTER cut
  ACTIVITY inspect DURATION 30min USING welder AFTER assemble
    RELEASE 0min DEADLINE 17h
  MINIMIZE makespan
  RESULTS TABLE fabrication_schedule
END

selected_plan = fabrication
plan_name = selected_plan.name

The reference record has schema grid.planning.reference.v1 and fields name, tickSecondsNumerator and tickSecondsDenominator (exact decimal strings). Its name resolves to the model's native resident PlanHandle; transient numeric handle IDs are not portable reference identities. Pass reference.name to the existing resolvePlanning(modelId, name) operation. It refers to the current named definition in that model, including resident repairs; it is not a frozen copy, a cross-model reference, or a solved schedule. Bytecode retains both the reference and native definition without requiring source text.

The plan name is an immutable module binding. Collisions with another binding or callable, duplicate definitions and rule writes are errors. Previously, PLAN name occupied a separate namespace; models that reused that name for an unrelated value must rename one of them. Resource and activity namespaces remain local and distinct.

This schedule starts at ticks 0, 240 and 540 and finishes at 570. One tick is one minute. resolvePlanning(modelId, "fabrication") selects this definition; resolvePlanning(modelId) retains the existing all-plans operation. Legacy region-index patch and definition APIs keep their existing meaning.

Clause Meaning
TICK duration Required positive fixed duration. Schedule timestamps and durations must be exact nonnegative multiples.
RESOURCE name [CAPACITY integer] Declared resource; capacity defaults to 1.
CALENDAR name = [[start, end], ...] Ordered, nonoverlapping half-open availability windows. Omitted means always available; [] means unavailable.
ACTIVITY name DURATION duration USING resource Required nonpreemptive work with positive duration.
ALTERNATIVES [resource, ...] Ordered alternative resources for that activity.
DEMAND integer Positive resource demand, default 1.
SETUP class Static setup label on an activity, used by its selected resources.
CHANGEOVER resource FROM class TO class DURATION duration Directed transition time on a unary resource. Initial use and unspecified transitions take zero setup time.
CHOICE (role = resource, ...) DURING [start, end] Offer one complete resource combination within a finite occupancy window, including setup.
NO CHOICES Explicitly offer no placement for this activity.
FIXED AT start USING (role = resource, ...) Pin the work start and complete resource combination to an offered choice.
AFTER predecessor or AFTER [a, b] Dependencies, including references to later activities. Cycles are refused.
RELEASE duration, DEADLINE duration Earliest start and latest completion, in time from the plan origin.
OPTIONAL PENALTY integer Permit omission, paying the stated cost. Compatible with total cost or explicit objective priorities.
DUE duration PENALTY integer PER duration Soft completion target and lateness rate. Compatible with total cost or explicit objective priorities.
MINIMIZE makespan or MINIMIZE total_cost Exactly one objective; total cost adds omission and lateness penalties to makespan in ticks.
MINIMIZE omission_cost THEN tardiness_cost THEN makespan Exact supported priority order, with finite deadlines and explicit windowed choices.
BUDGET integer NODES Native search budget; omitted uses 100,000. Zero retains the greedy incumbent without proof search.
REPAIR BUDGET integer NODES Native repair budget; omitted uses 2,000.
RESULTS FRAME name, RESULTS TABLE name Optional existing publication destinations. Names must not collide with model bindings or another plan's output.

Resource and activity names occupy separate, case-insensitive namespaces. Every resource must be declared. Time operands accept fixed duration suffixes (ms, s, min, h, d, w), fixed ISO duration literals, and pure constant arithmetic (+, -, *, /, parentheses and preceding constant bindings). Bare numbers are not times. Fractional ticks, overflow, reactive inputs, month/year durations and calls are refused. Compile-time arithmetic is checked within signed 128-bit rational bounds; native counts must fit their u32/u64 contracts. Changing ordinary model inputs does not rewrite immutable plan data. Preceding bindings retain normal Grid literal semantics; numeric bindings above the safe integer range are refused. Write large node budgets directly in the PLAN block to preserve their exact integer spelling.

Reports retain the native feasibility/budget status, assignments, makespan and repair evidence. The optional authoring metadata gives the plan name and a reduced exact tick scale in seconds (tickSecondsNumerator and tickSecondsDenominator, decimal strings). The compiled artifact carries this data, so loading an archive does not require the original Grid source.

Simultaneous-resource search prunes a branch when its optimistic cost cannot improve the current schedule. The bound accounts for release times, durations, soft due dates and optional omission penalties; resource delays can only raise it. A matching bound can prove optimality without enumerating activity orders. Canonical tie-breaking still examines equal-cost alternatives. BUDGET 0 NODES continues to return an incumbent without a proof, and difficult plans may still exhaust a positive budget with status feasible.

Simultaneous resource requirements

An activity can require several resources at once. Give each requirement a role name and a list of alternatives:

workshop = PLAN TICK 1min
  RESOURCE ana
  RESOURCE ben
  RESOURCE laser
  RESOURCE mill

  ACTIVITY machine_part DURATION 30min
    USING operator FROM [ana, ben]
    AND machine FROM [laser, mill]

  MINIMIZE makespan
END

The scheduler chooses one operator and one machine and reserves both for the entire activity. FROM lists independent alternatives for a role; AND adds another required role. This differs from scalar USING laser ALTERNATIVES [mill], which reserves only a machine. A role may have one option, such as FROM [laser]. Splitting a simultaneous activity into separate activities would change its meaning.

Each role accepts DEMAND positive_integer, default 1. For example, USING crew FROM [day_team, night_team] DEMAND 2 reserves two capacity units on one selected team; it does not select two different resources. Demands on a resource shared by several roles add together. Roles are case-insensitive and unique within one activity. Resource and activity namespaces stay separate from roles and ordinary constant bindings. Every resource must be declared, and every listed option must have capacity for that role’s demand. Empty lists and repeated options are errors. Finish the requirement list before AFTER, RELEASE, DEADLINE, SETUP, OPTIONAL or DUE. Scalar and grouped forms cannot be mixed in one activity, but different activities in one plan may use either form.

A plan containing groups selects the existing simultaneous v5 contract. Native admission bounds apply: at most 16 roles per activity, 64 alternatives per role, 4,096 combinations per activity and 65,536 combinations across a plan, plus 4,096 activities/resources and search/repair budgets no greater than 1,000,000. The native contract validates capacity and graph structure; infeasibility and budget exhaustion remain distinct runtime results. All times and demands still use immutable compile-time data. Reports keep every role assignment, the source name and exact tick scale; reference reads still do not solve or publish. See the complete workshop example.

Scalar-only plans retain v4 payloads; simultaneous groups use v5. Explicit choices, occupancy windows and fixed appointments select v6, as described below. Planning does not require the optional MIP backend.

Sequence-dependent setup

Use SETUP labels when preparation time depends on the previous activity on a resource. Directed CHANGEOVER declarations can have different durations:

painting = PLAN TICK 1min
  RESOURCE booth

  CHANGEOVER booth FROM red TO blue DURATION 15min
  CHANGEOVER booth FROM blue TO red DURATION 10min

  ACTIVITY first_batch DURATION 20min USING booth SETUP red
  ACTIVITY second_batch DURATION 20min USING booth SETUP blue
    AFTER first_batch

  MINIMIZE makespan
END

The first batch finishes at minute 20. Setup then occupies the booth for 15 minutes, and the second batch runs from 35 to 55. Reversing the two class labels finishes at minute 50. Initial use, a same-class transition without an explicit entry, and any other unspecified transition have zero setup duration.

Class names are case-insensitive identifier labels, not expressions or model bindings. SETUP applies to scalar and simultaneous activities, including an entire indexed activity family. Each selected resource uses its own directed transition table. Alternatives can avoid a changeover by selecting another resource. Resources with changeovers must have capacity 1; native admission rejects higher capacity rather than silently ignoring setup.

Setup is non-anticipatory: it follows the activity's release and predecessors. The whole setup-plus-work occupancy must fit resource availability. For a booth calendar [[0min, 30min], [40min, 100min]], the example's second setup starts at 40, work starts at 55, and work finishes at 75. Reports retain setup start and duration separately from work start and end.

Durations use the same immutable data and exact tick rules as other PLAN time operands. A keyed resource such as booth[:east] selects one member; CHANGEOVER booth[*] FROM red TO blue DURATION 15min applies to every member. Duplicate directed transitions are errors, including overlapping wildcard and member declarations. Expansion is capped at 65,536 transitions per plan. Checked binding renames update resource and activity references while leaving class labels intact. The compiled payload reuses native setup fields; reference reads still do not solve or publish. See the parameterized painting example.

Allowed combinations, occupancy windows and appointments

Independent resource groups permit every combination of their alternatives. Use CHOICE when only particular combinations are allowed:

workshop = PLAN TICK 1min
  RESOURCE ana
  RESOURCE ben
  RESOURCE laser
  RESOURCE mill
  ACTIVITY machine_part DURATION 30min
    USING operator FROM [ana, ben] AND equipment FROM [laser, mill]
    CHOICE (operator = ana, equipment = laser) DURING [0min, 4h]
    CHOICE (operator = ben, equipment = mill) DURING [1h, 8h]
    FIXED AT 2h USING (operator = ben, equipment = mill)
  MINIMIZE makespan
END

This activity can use Ana with the laser, or Ben with the mill. The appointment chooses Ben and the mill, starts work at minute 120, and finishes at minute 150. A tuple supplies every role exactly once; its resource must belong to that role's alternatives. Plain USING bench has the native role resource, written CHOICE (resource = bench) DURING [0min, 1h].

DURING bounds the entire half-open occupancy, including setup before work. FIXED AT pins the work start. Calendars, release times, hard deadlines, predecessors, capacity and setup still apply. A fixed tuple absent from the offered choices is invalid; a well-formed appointment that cannot fit its window or other hard constraints produces an infeasible schedule.

Every activity in a windowed plan needs CHOICE declarations or NO CHOICES. No window is inferred. NO CHOICES allows optional work to be omitted, makes mandatory work infeasible, and cannot satisfy a fixed appointment. Repeating a tuple with different windows is allowed; duplicate tuple/window pairs are errors. Windows and fixed times use exact immutable duration expressions, including named parameters and activity-family binders. Resource keys select one member; tuples cannot contain [*].

The compiler reserves expansion before copying offers: at most 4,096 choices per activity, 65,536 total choices, and 65,536 tuple role entries including fixed appointments. Eligible-resource references have a separate 65,536-entry bound. Explicit choices avoid constructing the groups' Cartesian product. Source-free owning artifacts and checked renaming preserve role, resource and family-binder identity. These clauses select the existing native v6 compiled definition for total cost, or v7 for tuple priorities/diagnosis; reading the plan reference still does not solve it.

Objective priorities

MINIMIZE omission_cost THEN tardiness_cost THEN makespan minimizes the weighted omission penalties first, then weighted lateness, then completion time. Any nonempty subset and permutation of these components is supported. Weights come from OPTIONAL PENALTY and DUE … PENALTY … PER; zero weights are allowed. Hard deadlines and explicit windows are optional. Search compares exact integer tuples directly, and reports the selected components in priorities.

objective_cost remains the ordinary makespan-plus-penalties total for reporting; it does not rank a priority objective. With an optional ten-minute job and an omission penalty of one, omission-first priorities favor completing the job when feasible even if its lateness is expensive. Makespan-first priorities can prefer omission. See General PLAN priorities for examples, budget behavior and compatibility with older serialized plans.

Optional activities and soft due dates

Use a due date when late work remains useful, and an omission penalty when work may be skipped. Both apply to scalar and simultaneous activities:

dispatch = PLAN TICK 1min
  RESOURCE machine

  ACTIVITY urgent DURATION 2min USING machine
    DUE 2min PENALTY 10 PER 1min
  ACTIVITY routine DURATION 3min USING machine
    DUE 4min PENALTY 1 PER 1min
  ACTIVITY polish DURATION 10min USING machine
    OPTIONAL PENALTY 2

  MINIMIZE total_cost
END

The best schedule completes urgent work at minute 2 and routine work at minute 5, and omits polishing. Its cost is 5 + 1 + 2 = 8: five ticks of makespan, one unit of lateness cost, and two units of omission cost.

total_cost is the existing native weighted sum: makespan in ticks plus omission penalties plus max(0, completion - due) times each admitted lateness rate. It does not express strict priority or minimize tardiness alone. DEADLINE still forbids late completion; an activity may have both a soft due date and a hard deadline. A required activity cannot depend on an optional predecessor. An omitted optional predecessor prevents scheduling its optional successors under the existing native contract.

Penalties are nonnegative integers, including zero. The PER duration must be positive. The compiler converts its rate to an exact integer cost per tick: PENALTY 10 PER 1min with TICK 30s becomes five per tick. An inexact rate is refused rather than rounded. Due dates must fall on whole ticks; the rate's period need not, provided its conversion is exact. All operands use the same immutable constant rules as other PLAN clauses. Changing TICK changes the makespan contribution relative to omission costs; rates retain their authored physical time meaning. Costs and native arithmetic retain each selected scheduler contract's integer limits.

These clauses support MINIMIZE total_cost or explicit objective priorities, including zero penalties. Older MINIMIZE makespan plans keep their meaning and native payloads. Owning syntax preserves cost operands for rendering and checked edits. A PLAN still binds a resident definition reference; explicit operations solve and publish. See the complete example.

13.3 Named parameter data

Use PARAMETER to attach readable domain keys to immutable arrays:

jobs = [:cut, :pack]
times = [20min, 10min]

batch = PLAN TICK 1min
  PARAMETER length[job IN jobs] = times
  RESOURCE machine
  ACTIVITY cut DURATION length[:cut] USING machine
  ACTIVITY pack DURATION length[:pack] USING machine AFTER cut
  MINIMIZE makespan
END

One axis addresses a row or column vector. Two axes address matrix rows and columns: PARAMETER length[stage IN stages, job IN jobs] = times, read as length[:cut, "large"]. Keys are symbols, text or exact integers; integer keys are labels, not array positions. Each domain must be nonempty, unique and use one representation. Array order defines the correspondence with domain order. Use the same key representation when reading a parameter.

PLAN captures all data at admission. Initializers and domains read the outer module scope, even if a parameter has the same name as an outer binding. Parameters cannot initialize other local parameters. Inputs, state, defaults, reassigned bindings and rule-written dependencies are refused, including transitive aliases and unused data. A changed definition requires compilation and explicit replacement; reading a PLAN reference still performs no search. SOLVE's parameter values remain reactive under its existing contract.

Parameter arrays contain exact numeric or duration constants and pure arithmetic; each array uses one of those two kinds. Indexed reads can appear in numeric and time fields, including capacities, demands, calendar endpoints, penalties and budgets. Normal field rules still apply: positive durations and capacities, whole ticks, and exact integer costs. An unknown key, wrong shape or arity, optional access, slice, or nonconstant data refuses admission. There are at most 1,024 parameters, 4,096 keys per axis and 65,536 data cells across a PLAN.

Parameter data and member declarations are separate. See the parameter example, which combines named data with optional work and soft due dates. The two-stage flow-shop example uses a matrix of processing times and has a seven-minute optimum.

13.4 Indexed resources and activities

Declare a family when several resources or activities share a structure:

batches = [:small, :large]
stages = [:cut, :pack]
processing_times = [2min, 4min; 1min, 2min]

flowshop = PLAN TICK 1min
  PARAMETER length[stage IN stages, batch IN batches] = processing_times
  RESOURCE station[stage IN stages]

  ACTIVITY cut[batch IN batches] DURATION length[:cut, batch]
    USING station[:cut]
  ACTIVITY pack[batch IN batches] DURATION length[:pack, batch]
    USING station[:pack] AFTER cut[batch]

  MINIMIZE makespan
END

Each declaration expands in domain order when the definition is admitted. One or two axes are supported; the last axis varies fastest. Resource capacities and activity operands can use their declaration's index names. Domains read the outer immutable scope, and an index cannot shadow a local parameter or another index in the same declaration. Outer aliases retain their original meaning even when an index has the same name as one of their dependencies.

station[:cut] selects a resource by its typed key. AFTER cut[batch] selects the matching predecessor; AFTER cut[*] waits for every member. In a resource group, USING operator FROM [worker[*]] makes every worker an alternative for that role. CALENDAR worker[*] = [[2min, 10min]] gives every worker the same availability; CALENDAR worker[:ana] selects one. A resource may have only one calendar declaration, including overlaps between keyed and whole-family forms.

[*] selects a whole family where a list is expected. It cannot select a single resource after USING, mix with explicit keys, or partially slice a two-axis family. Bare family names, unknown keys, wrong key arity and duplicate expanded references are errors. Symbols, text and integer keys remain distinct; the expanded native names retain those distinctions.

Expansion preserves the existing scalar or simultaneous scheduling contract. A PLAN admits at most 4,096 resources and 4,096 activities; expanded names must fit the native 128-byte identifier limit. Resource groups retain their limits of 16 groups, 64 alternatives per group and 4,096 combinations per activity. Total combinations, predecessor relations and calendar windows are each bounded at 65,536. Excessive expansion is refused rather than truncated.

The assignment still binds a resident definition reference. Reading it does not run the scheduler; explicit Planning operations solve, repair and publish. Changing a family domain or captured data requires recompilation. See the indexed flow shop.

14. See Also

Reusable SOLVE definitions

allocate = SOLVE(capacity)
  VARIABLE x IN [0, 2]
  VARIABLE y IN [0, 3]
  REQUIRE capacity_limit: x + y <= capacity
  MAXIMIZE 3*x + 2*y
END

small = allocate(4)
large = allocate(5 AS capacity)

The results have objectives 10 and 12. SOLVE allocate(capacity) is the alternative header. Parentheses create an ordinary callable, including the zero-argument SOLVE() form; a header without parentheses retains its existing reactive result binding. Unused definitions perform no solver work.

Constructors support up to 64 unique case-insensitive formal names, inferred callable types, named arguments, aliases, partial application, imports and re-exports. They can be declared inside DO/function bodies and passed to other callables. They cannot escape as cell/record/STATE data. Immutable lexical constants may be captured; pass cells, inputs, defaults, STATE, Tables and rule-written values as arguments. Aliases and pure helper dependencies obey the same rule.

Scalar arguments must be finite, untagged numbers, even when unused or cancelled from the objective. An array argument gets its exact shape from a PARAMETER with fixed domains:

allocate_products = SOLVE(demand, margin)
  PARAMETER need[p IN ["alpha", "beta"]] = demand
  PARAMETER value[p IN ["alpha", "beta"]] = margin
  VARIABLE quantity[p IN ["alpha", "beta"]] IN [0, need[p]]
  REQUIRE capacity: SUM(p IN ["alpha", "beta"], quantity[p]) <= 4
  MAXIMIZE SUM(p IN ["alpha", "beta"], value[p] * quantity[p])
END

allocation = allocate_products([2, 3], [3, 2])

One-axis formal data uses a row vector; two-axis data uses the declared row and column counts. The existing sparse solver ingress checks the complete shape and all numeric entries on every invocation. Domains and variable/constraint counts remain static; a formal cannot supply a structural domain. Constructor formals cannot also name local variables or PARAMETER views. All existing affine, convex ABS/MAX and ordered objective forms retain their contracts, reports and explicit backend failures. Calling a constructor creates no retained job.

See reusable FIT definitions for the same callable syntax applied to statistical models.

Bounded SOLVE and PLAN diagnosis

DIAGNOSE requests additional investigation explicitly:

allocation = SOLVE
  VARIABLE supply IN [0, 10]
  REQUIRE demand: supply >= 12
  MINIMIZE supply
  DIAGNOSE BUDGET 50 SOLVES
END

allocation.report.diagnosis names the conflicting requirement and upper bound, including their source byte offsets. Indexed members retain their expanded labels. irreducible means every retained encoded row or bound is needed for that conflict, with integrality fixed. It does not mean the smallest possible conflict. Equality requirements have two encoded rows. Generated objective rows have no invented source location. Numerical feasibility tolerances still apply.

A diagnosis budget counts feasibility calls, each subject to native effort limits. It is separate from lexicographic iteration reservations and is not a wall-time or peak-memory guarantee. Limits are 4,096 candidate members and 4,097 calls. Zero calls do no investigation. budget_exhausted, cancelled, inconclusive, and invalid_problem never claim irreducibility. infeasibility_proven says whether the retained subsystem was proved infeasible before stopping. The investigation excludes objective protection rows added by lexicographic stages. Byte spans are present when compiled from source text and survive stripped compiled models. Source-free owning artifacts retain indexed labels but omit unavailable text positions; a missing position is never presented as byte zero. Ordinary SOLVE reports perform no diagnostic search.

PLAN accepts DIAGNOSE BUDGET 1000 NODES, with a maximum of one million nodes. Its selected result adds diagnosis: bounded search events with activity names, source offsets and a small partial-schedule context. These events describe local dead ends, not a conflict core or instructions for removing constraints. Only a complete infeasible diagnostic search sets infeasibilityProven. The report separately records its node count, budget and truncated event/context lists. A validated feasible schedule needs no extra diagnostic search.

General PLAN priorities

Author any nonempty subset of these distinct components in the desired order:

MINIMIZE tardiness_cost THEN omission_cost THEN makespan

omission_cost is the sum of penalties for omitted activities, tardiness_cost is the weighted lateness cost, and makespan is the final completion tick. A later component breaks ties in earlier components. Search compares tuples and optimistic tuples directly; it does not multiply priorities by large weights. Finite deadlines and positive omission penalties are no longer prerequisites. A zero omission weight is a deliberate tie, not mandatory presence. Native integer limits still apply to each component and the reported total; removing scalar multipliers does not permit overflowing those values.

MINIMIZE total_cost retains the weighted-sum contract. With soft clauses, MINIMIZE makespan minimizes only makespan and can omit optional work even when omission is expensive. Result objectiveCost remains the ordinary sum of makespan, omission and tardiness; it is not a scalar encoding of the priority tuple. The selected result adds priorities, with each authored component and its achieved integer value. feasible means the search found a schedule but did not finish its proof. optimal requires completion or a valid optimistic bound.

New priority sources select Planning schema/dialect 7. Previously compiled schema-6 scalarized priorities keep their original objective and tie contract. New tuples retain the first optimum in deterministic search order; they do not spend additional search effort choosing a canonical assignment among equal optima.

Configured PLAN instances

Parentheses make a top-level PLAN definition callable:

input cut_time = 8min

fabricate = PLAN(cut_duration, pack_duration) TICK 1min
  RESOURCE bench
  ACTIVITY cut DURATION cut_duration USING bench
  ACTIVITY pack DURATION pack_duration USING bench AFTER cut
  MINIMIZE makespan
END

request = fabricate(cut_time, 4min)
other = fabricate(2min, 3min)

Calls construct independently validated, immutable instance DATA. Reading either binding runs no scheduler and publishes no Frame or Table. Changing cut_time reconstructs request; other keeps its independent values. The tick scale is fixed by the definition. Duration fields require typed fixed durations and exact whole ticks; capacities, demands, penalties and budgets require exact nonnegative integers. Rate periods can be fractional ticks if the resulting per-tick cost is an exact integer. Invalid values produce a PLANNING/INSTANCE error.

This constructor profile supports flat activities, simultaneous resource groups, calendars, setup classes and changeovers, optional work, due dates, objective priorities and diagnosis. Indexed families, explicit choices/fixed appointments, and RESULTS remain static PLAN features in this profile; constructor admission refuses them. Local PLAN definitions inside DO are not supported by this initial profile.

Changeover durations can be constructor arguments or pure expressions, while resource names and setup classes remain fixed:

paint = PLAN(wash_time) TICK 1min
  RESOURCE booth
  CHANGEOVER booth FROM red TO blue DURATION wash_time
  ACTIVITY first_batch DURATION 3min USING booth SETUP red
  ACTIVITY second_batch DURATION 3min USING booth SETUP blue AFTER first_batch
  MINIMIZE makespan
END
request = paint(2min)

This instance needs eight ticks: three for each batch and two for the changeover. Transitions are directed; author the reverse transition separately if needed. Duplicate resource/from/to transitions and unknown resources are refused. Changeover durations use the same exact whole-tick admission as activity durations.

The existing resolvePlanning operation accepts [modelId, "request"] to solve the current instance. To publish through the existing output owners, pass a third argument such as {"table":"request_schedule"} or {"frame":"request_frame"}. Output names are explicit operation inputs. Native prepared-operation tokens are not serializable and are bound to the resident model image and input revision; a foreign model, reload or intervening input change requires selecting the live binding again. Public instance DATA is not an operation token.