noir-circuit-patterns · git:20260612.13b231c · 2026-06-12 · sha256 e0d231119a23620a

noir-circuit-patterns git:20260612.13b231cA

Immutable. This exact content is served forever at /api/v1/blob/e0d231119a23620a.

---
name: noir-circuit-patterns
description: Patterns and gotchas for writing Noir circuits that implement SPARQL primitives over committed RDF graphs. Use when implementing BGP matchers, joins, filters, projections, or hashing-to-field strategies; when sizing constraint budgets; when laying out public inputs that bind to a commitment scheme; or when interfacing nargo / Barretenberg (bb) tooling. Always confirm syntax against the noir-lang context7 docs (/noir-lang/noir) before writing code.
---

# Noir circuit patterns for ZKP-SPARQL

Reusable patterns for the Noir circuits that prove correct SPARQL
evaluation over committed RDF graphs. These are starting points;
verify any specific syntax via the `/noir-lang/noir` context7 docs
before pasting into code.

For generic Noir guidance the upstream skills cover better:
`noir-developer` for circuit structure / stdlib / workspace setup,
`noir-idioms` for the idiomatic ACIR-vs-Brillig style, `noir-testing`
for the testing framework. This skill is intentionally narrow to:
SPARQL-domain patterns (BGP, joins, filters, triple commitments),
plus the Noir-language gotchas this codebase has hit specifically
(orphan rule, `pub(crate)` for verifier-hint soundness, trait
associated constants in const-generic positions, IEEE-754
`total_compare` vs `Ord`, witness-vs-comptime branches). For cost
analysis on a candidate gadget see the sister skill `noir-optimisation`.

## Always-do

- **Pin the toolchain.** `Nargo.toml` declares `compiler_version`.
  README of the circuit dir documents the matching `bb` version.
- **Query context7 first.** Before guessing a stdlib function name, a
  trait bound, or a generic syntax, run
  `mcp__context7__query-docs` against `/noir-lang/noir`. The Noir
  language has evolved fast; recall is unreliable.
- **Document `main`'s public inputs.** A commented-out line per
  public input naming what it commits to and why.
- **Test every primitive.** Noir supports `#[test]` in source — use
  it. Cover an accept case and at least one reject case.
- **Comment constraint cost.** When writing a non-trivial gadget,
  estimate where the dominant cost lies (hashing? bit-decompositions?
  range proofs?) and note it.

## Triple commitment patterns

The credential graph is committed before the circuit consumes it. The
two natural shapes:

- **Per-triple Pedersen / Poseidon hash, accumulated into a Merkle
  tree.** The prover supplies a triple + a Merkle path; the circuit
  verifies the path against a public root. Best when triples are
  accessed sparsely (e.g. BGP with few patterns).
- **Sorted sequence committed via a single sponge hash.** The prover
  supplies the full ordered triple list; the circuit re-hashes and
  asserts equality with a public commitment. Best when most of the
  graph is touched anyway, or when sortedness is needed downstream
  (e.g. for sort-merge joins).

Either way, **domain-separate** the hash inputs (e.g. tag a triple
hash with a `b"triple-v1"` prefix) and document the tag in a
single table shared with `vc-cryptography`.

## BGP matching

For a triple pattern `(s ?x p o ?y)` with two variables:

1. Extract the candidate triple from the committed graph (Merkle
   path or sponge index).
2. Constrain the constants (`s`, `p`) to equal the corresponding
   triple positions.
3. Bind the variables (`?x`, `?y`) to the remaining positions.
4. The output is a witness binding for downstream operators.

Multi-pattern BGPs are joins over the per-pattern bindings — see
joins.

## Joins

- **Sort-merge** when both sides are sorted on the join key (cheap
  per-row, but needs a sort proof or pre-sorted commitment).
- **Lookup-table / set-membership** when one side is small and can be
  pre-loaded as a polynomial commitment.
- **Hash join** is rarely the right choice in-circuit because
  building a hash table burns constraints linear in the build side.

For a paper at the BGP level, sort-merge against a graph already
committed as a sorted sponge is usually the right default.

## Filters

`FILTER (?x > 5)` and friends decompose into:

- a comparison gadget (use `std::cmp` where it exists; else a
  bit-decomposition).
- a conditional select that drops the row.

Comparisons on field elements need a bit-decomposition unless the
values are known small. Document the bit budget in a comment.

## Projection / DISTINCT

Projection is free; DISTINCT requires either:

- a sort + adjacent-deduplicate pass, or
- a multi-set hash (commit to the unordered solution multi-set and
  argue equality).

The multi-set hash route plays nicely with Barretenberg's native
field; prefer it unless the verifier explicitly wants the result
ordered.

## Public input layout — the contract

Every `main` exports a precise public-input layout:

```
fn main(
    // -- public --
    graph_commitment: Field,        // Pedersen / Poseidon root over committed triples
    query_commitment: Field,        // commitment to the SPARQL query the prover claims to evaluate
    result_commitment: Field,       // commitment to the disclosed result multi-set
    // -- private --
    graph_witness: ...,             // triples + Merkle paths
    binding_trace: ...,             // intermediate operator bindings
    // ...
) -> pub Field {
    // verify graph commitment, evaluate, re-commit result, equality-assert
}
```

The exact shape evolves; what's invariant is that the verifier sees
*only* the three commitments + the result.

## Tooling cheats

- `nargo check` — fast type-check.
- `nargo test` — runs `#[test]` functions.
- `nargo execute` — run a circuit with a `Prover.toml` and inspect
  intermediate witnesses; useful for debugging before proving.
- `bb prove` / `bb verify` — proving / verifying via Barretenberg.
- `nargo info` — constraint count and gate breakdown; the budget
  document.

## When to escalate

If a primitive doesn't fit the constraint budget, **escalate to
`sparql-semantics` and the main session before optimising blindly** —
the right move may be to refine the supported fragment, not to
golf the gadget.

## See also

- `noir-optimisation` — cost model + decision rules. Read before
  spiking any `unconstrained + verified` primitive: §2 has the
  profitability conditions, §3 the bit-decomposition vs dynamic-shift
  trade-off, §8 the pre-spike checklist.

## Trait associated constants

Noir traits can declare associated constants with `let CONST: u32;`
syntax. From the Noir docs [t1]:

```rust
trait MyTrait {
    type Foo;
    let Bar: u32;
}

impl MyTrait for Field {
    type Foo = i32;
    let Bar: u32 = 11;
}
```

The constant can then be used in const-generic positions, e.g.
`x.assert_max_bit_size::<{ Self::EXP_BITS }>()`. In beta.16/17 the
expression in the const-generic slot is restricted: **only `+`, `-`,
`*`, `/`, `%` are accepted in the initialiser** *(verify on your
exact Noir version)*. Bitwise operators and function calls there
are not.

The IEEE 754 trait at `ieee754/src/float.nr:28-46` uses this
pattern as the foundational design choice:

```rust
pub trait IEEEFloat {
    let EXP_BITS: u32;
    let MANT_BITS: u32;
    // ...
}

impl IEEEFloat for Float32 {
    let EXP_BITS: u32 = 8;
    let MANT_BITS: u32 = 23;
    // ...
}
```

The default method `compose` at line 133-138 uses these in
const-generic position:

```rust
exponent.assert_max_bit_size::<Self::EXP_BITS>();
mantissa.assert_max_bit_size::<Self::MANT_BITS>();
```

Critical design note from `ieee754/src/float.nr:39-43`: the trait
keeps `EXP_BITS` / `MANT_BITS` as **two independent constants** rather
than deriving them from a single `TOTAL_BITS`, because
`assert_max_bit_size::<N>` requires a const-generic expression in
`N`, and the beta-era restriction on which arithmetic is allowed
there made `TOTAL_BITS - EXP_BITS - 1` not always acceptable. Keep
this in mind when adding new layout constants — prefer declaring
them as separate associated constants over deriving them with
complex arithmetic in const-generic positions.

For cost consequences (folding, monomorphisation), see
`noir-optimisation` §10-§11.

## Const generics vs trait associated constants

Noir has two related mechanisms for compile-time numerics on
generic items:

- **Const generic parameters:** `<let N: u32>` on a free function,
  struct, or `impl` block [t2]. Example:
  `struct BigInt<let N: u32> { limbs: [u32; N] }`. The value is
  supplied at the call / instantiation site.
- **Trait associated constants:** `let CONST: u32;` declared in
  the trait and filled in by each `impl`. The value is fixed per
  implementation; callers select via the `Self` type.

Pick the one that matches the dependency: const generic parameter
when the value varies per call (different array widths in the same
code path); trait associated constant when the value is intrinsic
to a type (an IEEE 754 format's layout is intrinsic to that
format, not chosen per call site).

## Witness-vs-comptime branches

`if c { a } else { b }` has two compilation modes:

- If `c` is comptime-known (a literal, a `comptime global`, a
  trait associated constant, or the result of a `comptime fn`),
  the SSA passes fold the condition before `flatten_cfg` runs and
  only the chosen arm contributes constraints.
- If `c` involves any witness, the `flatten_cfg` SSA pass
  rewrites the `if` to a conditional select: both arms execute
  and their constraints both apply, with the result multiplexed
  on `c`.

Consequence for structural patterns:

- A `BGP` matcher that branches on a constant pattern position
  (`if subject_is_iri { ... }`) folds when `subject_is_iri`
  comes from the query commitment.
- A filter that branches on a witness value (`if x > 5 { keep }
  else { drop }`) pays for both arms even when only one is
  "selected"; structure the filter as a single straight-line
  expression with a conditional multiplier instead.

See `noir-optimisation` §6 and §13 for the SSA-pass detail.

## Adversarial-prover testing

For the basics of `#[test]` / `#[test(should_fail)]` /
`#[test(should_fail_with = "...")]` and general test organisation,
see the `noir-testing` skill (assertion patterns, test attributes,
test organisation).

Convention specific to this workspace: every `unconstrained + verified`
pattern needs an accept-path `#[test]`, plus at least one
`should_fail_with` per distinct failure mode the verifier's clauses
encode. Feed deliberately wrong witnesses through the production
`verify_*_relation` helper and assert each specific tampering is
caught by name. The shared verifier helper (see `noir-optimisation`
§7 "Shared-test-helper anti-pattern") means adversarial tests and
production calls share verifier code, so regressions surface in both.

Example from `ieee754/src/unconstrained_ops.nr`:

```rust
#[test(should_fail_with = "shift==0 quotient mismatch")]
fn rejects_shift0_quotient_mismatch() { ... }
```

The message-match is essential -- it pins WHICH clause caught the
lie, not just that some clause did. Drop a `should_fail_with` for
each clause in the verifier's safety proof.

## Orphan rule

Noir enforces the orphan rule for trait implementations: you
cannot write `impl Foreign for Foreign` — at least one of the
trait or the type must be defined in the current crate. This
blocks the natural-looking:

```rust
// Rejected: both `From` and `Field` are foreign.
impl From<Float32> for Field {
    fn from(f: Float32) -> Field { ... }
}
```

The workaround used throughout this codebase is to define an
**inherent method** instead of an impl of a foreign trait. From
`ieee754/src/float.nr:141-145`:

```rust
fn to_field(self) -> Field {
    self.sign() * Self::sign_field()
        + self.exponent() * Self::implicit_bit_field()
        + self.mantissa()
}
```

Call sites use `f.to_field()` rather than `Field::from(f)`. The
same convention applies in reverse — `From<Field>` for `Float32`
is fine (the impl is in the same crate as `Float32`), but bear in
mind whether the crate boundary supports the impl you want before
reaching for `From` / `Into`.

## Standard library traits in this stack

Noir's standard library exposes the conventional Rust-style
traits *(verify exact paths on your Noir version)*:

- `std::convert::From` / `Into`
- `std::ops::{Add, Sub, Mul, Div, Neg}` for operator overloading
- `std::cmp::{Eq, Ord}` for equality / ordering

**Watch out: not every operation a type "supports" has total
semantics.** IEEE 754 `<` is **not** total — comparing any value
to NaN returns `false`, so `a < b`, `a > b`, and `a == b` all
return `false` for NaN inputs simultaneously. This violates the
`Ord` contract (a total order). Implementing `Ord` for `Float32`
would either lie about the semantics or produce non-IEEE
results.

This codebase's resolution at `ieee754/src/float.nr:508`:
implement a separate `total_compare` method that follows IEEE
754-2008's `totalOrder` predicate, returning `i8` with order
`-NaN < -Inf < ... < -0 < +0 < ... < +Inf < +NaN`. NaN payloads
break the tie within same-sign NaNs by mantissa. The
trait-method form sidesteps `Ord` entirely; callers reach for
`total_compare` when they need lawful sortability and for
`ieee_eq` (`ieee754/src/float.nr:688`) when they need IEEE
equality (NaN ≠ NaN).

Rule: before implementing a foreign comparison / arithmetic
trait for a domain type, check whether the trait's contract is
satisfied. If not, expose the operation as an inherent method
under a name that signals the semantics (`total_compare`,
`ieee_eq`, etc.) and document why the trait was avoided.

## `pub(crate)` for soundness

Noir's visibility modifiers include `pub`, `pub(crate)`, and the
crate-private default [t3]. The `pub(crate)` modifier is the
load-bearing tool for keeping `unconstrained` hints sound.

**Rule: every `unconstrained fn` hint is `pub(crate)` (or
private), never `pub`.** A `pub unconstrained fn` exposes the
unverified Brillig hint through the crate's public API, letting
downstream callers wire the hint directly into their circuits
without the in-crate verifier — silently breaking soundness for
anyone who imports the crate.

This is not theoretical: PR #36 in this repo shipped a
`pub unconstrained fn count_leading_zeros_u23_unconstrained`, and
roborev's review forced the visibility change in commit `c7ecb8a`.
See `noir-optimisation` §7 "Unconstrained outputs leaking" for
the cost-and-soundness context.

The corresponding `pub(crate)` pattern at
`ieee754/src/unconstrained_ops.nr`:

```rust
// pub(crate) -- internal hint; only the verifier may call it.
pub(crate) unconstrained fn count_leading_zeros_u23_unconstrained(
    value: u32,
) -> u32 { ... }

// pub -- the verified wrapper is the only sanctioned public entry.
pub fn count_leading_zeros_u23_verified(value: u32) -> u32 {
    // Safety: `verify_clz_u23_relation` enforces the relation ...
    let count: u32 = unsafe { count_leading_zeros_u23_unconstrained(value) };
    verify_clz_u23_relation(value, count);
    count
}
```

The `pub(crate)` keeps the hint reachable from `verify_*_relation`
and from adversarial `#[test]`s in the same crate, while
preventing external callers from bypassing the verifier.

The same applies to `verify_*_relation` helpers themselves: they
are `pub(crate)` so the production wrapper *and* the adversarial
tests can both call them, but external crates cannot wire raw
relations into circuits the in-repo Lean proofs do not cover.

## References for new sections

t1. Traits — Noir docs,
    `docs/versioned_docs/version-v1.0.0-beta.20/noir/concepts/traits.md`.
    Source of the `let Bar: u32;` associated-constant syntax.
    Verified 2026-05-11 via context7.
t2. Generics — Noir docs,
    `docs/versioned_docs/version-v1.0.0-beta.20/noir/concepts/generics.md`.
    Source of the `<let N: u32>` const-generic syntax. Verified
    2026-05-11 via context7. The beta.16/17-era restriction to
    `+ - * / %` in the initialiser of an `assert_max_bit_size::<{ ... }>`
    expression is from in-repo experience (see
    `ieee754/src/float.nr:39-43` comment); verify on your exact
    Noir version.
t3. Visibility modifiers — `pub(crate)` is exemplified throughout
    this codebase (e.g. `ieee754/src/types.nr:23-25`,
    `ieee754/src/float.nr:688`,
    `ieee754/src/float32/add.nr:19`); the canonical Noir docs
    entry for visibility could not be confirmed via context7 in
    this round — verify on your Noir version before claiming
    exact behaviour beyond "restricts to current crate".