Skip to content

Assertions and Attributes

Assertions are post-evaluation checks that verify invariants and expected values. Attributes are metadata annotations on declarations. Together, they enable in-language testing and engineering assumption tracking.

Assert Declarations

An assert declaration checks a boolean condition after the entire computation graph has been evaluated:

assert fuel_positive = @fuel_mass > 0.0 kg;

Key Properties

  • Assert names conventionally use lower_snake_case, like param and node.
  • Assert bodies can reference any @param or @node, plus constants.
  • Assertions are leaf nodes -- no declaration can reference an assert with @. Attempting @my_assert is a compile error (A003).
  • Assertions are evaluated after the full graph, in declaration order.
  • A failed assertion produces a non-zero exit code.

Boolean Assertions

The simplest form evaluates an expression that must produce Bool:

param pressure: Pressure = 8.5 MPa;
param max_pressure: Pressure = 10.0 MPa;

assert pressure_safe = @pressure < @max_pressure;

If the body evaluates to false, the assertion fails with "assertion evaluated to false".

Indexed Boolean Assertions

When the body evaluates to Bool[SomeIndex], each variant is checked individually. Failing variants are listed in the diagnostic:

index Stage = { First, Second, Third };

param thrust: Force[Stage] = ...;
param min_thrust: Force = 100.0 kN;

assert all_stages_ok = for stage: Stage {
    @thrust[stage] > @min_thrust
};
// If First and Third fail:
//   FAIL  (failed at Stage.First, Stage.Third)

Comparison operators require unindexed operands and never broadcast. Use an explicit for comprehension, as above, to produce Bool[I] one element at a time. Passing an indexed collection directly to a comparison operator is a compile error (D019). The explicit spelling makes the assertion's axes and element pairing visible.

Tolerance Assertions

For approximate equality checks, use the ~= and +/- syntax:

assert fuel_budget = @fuel_mass ~= 2847.0 kg +/- 5.0 kg;

This is semantically equivalent to abs(@fuel_mass - 2847.0 kg) <= 5.0 kg but produces richer failure diagnostics showing the actual value, expected value, tolerance, and delta.

Absolute Tolerance

assert name = <actual> ~= <expected> +/- <tolerance>;

All three operands are arbitrary expressions. They can reference @param, @node, constants, call functions, and use arithmetic -- anything valid in a node expression. The dimension rules are:

  • actual and expected must have the same dimension.
  • tolerance must also have the same dimension as actual and expected.
  • tolerance must be non-negative. A literal negative tolerance is a compile error (A015); a tolerance computed at runtime that turns out negative makes the assertion report an ERROR. Zero tolerance is legal and means exact-match semantics.

The check passes when abs(actual - expected) <= tolerance.

Examples:

// All three operands can be graph references
assert mass_check = @computed_mass ~= @expected_mass +/- @mass_tolerance;

// Or mix literals, constants, and references
assert velocity_ok = @v_final ~= 3000.0 m/s +/- 10.0 m/s;

Expressing Relative Tolerance

Tolerance assertions have absolute semantics only. Express a relative percentage by calculating its absolute tolerance explicitly:

assert efficiency = @eta ~= 0.85 +/- abs(0.85) * 0.05;
// Passes if eta is within [0.8075, 0.8925]

assert velocity_approx = @velocity ~= 49.5 m/s +/- abs(49.5 m/s) * 0.05;
// Passes if velocity is within [47.025, 51.975] m/s

The expression after +/- is a full ordinary expression. % is exclusively the binary modulo operator; it has no assertion-specific trailing form.

Indexed Tolerance Assertions

Tolerance assertions have their own element-wise semantics; unlike ordinary comparison operators, they accept indexed operands. The assertion's index shape comes from actual; expected and tolerance are each either unindexed (applied to every key) or indexed by exactly the same axes in the same order (D011 otherwise):

index Case = { A, B };

node actual: Length[Case] = { Case.A: 1.0 m, Case.B: 2.0 m };
node expected: Length[Case] = { Case.A: 1.0 m, Case.B: 2.5 m };
node tol: Length[Case] = { Case.A: 0.01 m, Case.B: 0.6 m };

// Unindexed tolerance applied to every key:
assert close = @actual ~= @expected +/- 0.1 m;
//   FAIL  (failed at Case.B (actual 2, expected 2.5 +/- 0.1, off by 0.5))

// Per-key tolerance over the same axes:
assert per_key = @actual ~= @expected +/- @tol;

// Derive per-key relative tolerances explicitly as absolute quantities:
node relative_tol: Length[Case] = for case: Case {
    abs(@expected[case]) * 0.25
};
assert relative = @actual ~= @expected +/- @relative_tol;

Each failing key is reported with its own actual/expected/delta detail. Per-variant #[expected_fail(Case.B)] works on indexed tolerance assertions exactly as on indexed boolean ones.

Attributes

Attributes are metadata annotations written before a declaration using the #[name] or #[name(args)] syntax:

#[assumes(pressure_safe)]
node safety_factor: Dimensionless = 1.5;

Syntax

#[name]                                     // no arguments
#[name(arg1)]                               // one argument
#[name(arg1, arg2, arg3)]                   // multiple arguments
#[name(Index.Variant)]                     // qualified path argument
#[name((Idx.A, Idx.B), (Idx.C, Idx.D))] // tuple key arguments

Multiple attributes can be stacked:

#[lazy]
#[assumes(pressure_safe)]
node expensive: Dimensionless = heavy_computation(@data);

Unknown attribute names are compile errors (A007).

#[assumes(...)]

The #[assumes(...)] attribute documents that a declaration's value is valid only if the named assertion(s) hold. It does not create a graph dependency.

assert pressure_safe = @pressure < 10.0 MPa;

#[assumes(pressure_safe)]
node safety_factor: Dimensionless = 1.5;

When pressure_safe fails, the diagnostic mentions that safety_factor may be invalid:

Assertions:
  pressure_safe  FAIL  (assertion evaluated to false)
                       affected: safety_factor

Rules

  • Arguments must reference assert declarations. Referencing a param, node, const node, or nonexistent name is a compile error (A005).
  • Valid on node and param declarations. Using #[assumes] on const node is an error (A006) because constants do not depend on runtime values.
  • Multiple assertions can be listed: #[assumes(a, b, c)].
  • Cross-file: you can import an assert via import and reference it in #[assumes].

#[expected_fail]

The #[expected_fail] attribute marks an assertion that is expected to fail. This is useful for documenting known failures in engineering calculations without causing the overall evaluation to fail.

A failing assertion marked #[expected_fail] is treated as a pass. A passing assertion marked #[expected_fail] is treated as a failure ("unexpected pass"), since the known issue may have been resolved and the attribute should be removed.

// This assertion fails (10 > 20 is false), but it's a known issue
#[expected_fail]
assert x_greater = @x > @y;

Constraints

  • Valid only on assert declarations. Using #[expected_fail] on param, node, const node, etc. is a compile error (A008).
  • Evaluation errors (e.g., division by zero) are never inverted -- they remain errors regardless of #[expected_fail].
  • #[expected_fail] without arguments is valid only on unindexed assertions. Indexed assertions must list the exact expected-fail keys (A011).
  • Per-variant keys are valid only on indexed assertions (A010).
  • Each expected-fail key must be unique (A012).
  • Single-index keys must belong to the assertion's index. Multi-index tuple keys must include every axis in the assertion's axis order (A013, A014).

Blanket Form

When used without arguments, the entire assertion is expected to fail:

#[expected_fail]
assert known_issue = @actual == @expected;

Per-Variant Form (Single Index)

For indexed assertions, specific index variants can be marked as expected failures while other variants must still pass:

index Mode = { Normal, Eco, Boost };

#[expected_fail(Mode.Boost)]
assert power_ok = for m: Mode { @power_use[m] < @power_gen[m] };

Here, Mode.Boost is expected to fail (and is treated as a pass if it does), while Mode.Normal and Mode.Eco must still pass normally.

Per-Tuple-Key Form (Multi Index)

For multi-indexed assertions, tuple keys identify specific index combinations:

index Mode = { Normal, Eco, Boost };
index Phase = { Launch, Cruise };

#[expected_fail((Mode.Normal, Phase.Cruise), (Mode.Boost, Phase.Launch))]
assert within_limits = for m: Mode, p: Phase { @actual[m, p] < @threshold[m, p] };

Structural Finite Axes (#N Keys)

Assertions indexed by Fin(N) axes use positional #N keys, matching the slice-label syntax used by table expressions:

#[expected_fail(#1)]
assert steps_ok = for i: Fin(3) { @residual[i] < @tolerance };

In multi-axis tuple keys, each component uses its axis's key form — named axes use Index.Variant, while Fin axes use #N — in assertion axis order:

index Mode = { Normal, Boost };

#[expected_fail((Mode.Boost, #2))]
assert grid_ok = for m: Mode, i: Fin(4) { @value[m, i] < @limit };

The position must be in bounds: #N on a Fin(size) axis requires N < size (A016). A bare integer (#[expected_fail(1)]) is a parse error; positional keys always include the # prefix.

#[lazy]

Recognized by the parser but not yet implemented. Will mark a node for lazy evaluation (computed only when requested, not eagerly during graph evaluation).

Assertions in Multi-File Projects

When a file is imported (either selectively or as a module), all assertions in that file are automatically evaluated. You do not need to explicitly import assertions for them to be checked -- they run as part of the imported file's evaluation.

// checks.gcl
param limit: Dimensionless = 100.0;
assert limit_positive = @limit > 0.0;
// main.gcl
import checks.{limit};
// limit_positive is automatically evaluated and reported,
// even though it was not listed in the import braces.

This applies transitively: if a.gcl imports b.gcl, which imports c.gcl, then assertions in all three files are evaluated and reported.

In diamond imports (where two files import the same dependency), the shared dependency is evaluated once and its assertions are reported once.

Using #[assumes] with imported assertions

To reference an imported assertion in #[assumes(...)], you must explicitly import it by name:

// main.gcl
import checks.{limit, limit_positive};

#[assumes(limit_positive)]
node ratio: Dimensionless = @limit / 2.0;

Error Codes

Code Description
A001 Assertion failure (LSP diagnostic)
A002 Assumed assertion failed (CLI, lists affected nodes)
A003 Cannot reference assert with @ sigil
A004 Assert body must evaluate to Bool
A005 Unknown assert in #[assumes(...)]
A006 #[assumes] on invalid declaration kind (e.g., const node)
A007 Unknown attribute name
A008 #[expected_fail] on invalid declaration kind (not assert)
A009 Invalid argument in #[expected_fail(...)]
A010 #[expected_fail(...)] with variant args on non-indexed assertion
A011 #[expected_fail] without keys on indexed assertion
A012 Duplicate key in #[expected_fail(...)]
A013 #[expected_fail(...)] key has the wrong index shape
A014 #[expected_fail(...)] key uses the wrong assertion index
A015 Literal tolerance in ~= assertion is negative
A016 #[expected_fail(...)] Fin position is outside the indexed assertion axis