Skip to main content

Analysis & Tooling

4 min read

describePredicate – render a FactPredicate as English or algebra

A pure tree walker that turns any FactPredicate AST into a precise human-readable sentence. Closes the LLM-emit round-trip (intent → predicate → describe → reprompt), powers rules-diff prose, and turns audit-ledger entries into one-line decisions.


The shape

import { describePredicate } from "@directive-run/core";

describePredicate({ cartTotal: { $gte: 50 } });
// → "cartTotal is at least 50"

describePredicate(
  { $any: [{ region: "US" }, { region: "EU" }] },
);
// → "(region is US) OR (region is EU)"

describePredicate({ cartTotal: { $gte: 50 } }, { style: "formal" });
// → "cartTotal ≥ 50"

Pure. No side effects. No throws on a valid predicate — on cyclic / non-object input it returns a sentinel string so it's safe to call on LLM output, third-party data, or whatever else flows into the audit trail.


Options

OptionTypeDefaultWhat
style"natural" | "formal""natural"English prose vs algebra (, , , ¬).
localestring"en-US"Forwarded to Intl.NumberFormat. Invalid locales fall back to en-US.
parenthesizebooleantrueWrap each clause of a combinator in parens when there's > 1 child.
factName(path: string) => stringidentityMap fact paths to friendly labels (cartTotal"cart total"). Only consulted in "natural" style — formal style preserves raw dotted paths so the output stays algebraic.

Operators rendered

OperatorNaturalFormal
$eqx is V (x is null for null)x = V
$nex is not Vx ≠ V
$gtx is more than Vx > V
$gtex is at least Vx ≥ V
$ltx is less than Vx < V
$ltex is at most Vx ≤ V
$inx is one of V1, V2, …x ∈ {V1, V2, …}
$ninx is not one of V1, V2, …x ∉ {V1, V2, …}
$existsx is set / x is not set∃ x / ∄ x
$betweenx is between A and BA ≤ x ≤ B
$startsWithx starts with "foo"x ^= "foo"
$endsWithx ends with "foo"x $= "foo"
$containsx contains "foo"x ⊇ "foo"
$matchesx matches /re/x ~ /re/
$changedx changedΔx
$all(A) AND (B)A ∧ B
$any(A) OR (B)A ∨ B
$notNOT (A)¬(A)

Empty combinators degrade gracefully: { $all: [] }"always true" (), { $any: [] } and { $not: {} }"never" ().


Examples

Friendly fact names

describePredicate(
  { cartTotal: { $gte: 50 }, shippingRegion: { $in: ["US", "EU"] } },
  { factName: (p) => p.replace(/([A-Z])/g, " $1").toLowerCase() },
);
// → "(cart total is at least 50) AND (shipping region is one of US, EU)"

Formal style with locale formatting

describePredicate(
  { revenue: { $gte: 1_000_000 } },
  { style: "formal", locale: "de-DE" },
);
// → "revenue ≥ 1.000.000"

Cross-module pivot (nested object)

describePredicate({
  user: { age: { $gte: 18 }, region: "US" },
});
// → "(user.age is at least 18) AND (user.region is US)"

Combinator nesting

describePredicate({
  $all: [
    { $any: [{ tier: "pro" }, { tier: "enterprise" }] },
    { $not: { suspended: true } },
  ],
});
// → "((tier is pro) OR (tier is enterprise)) AND (NOT (suspended is true))"

Cycle handling, depth limits, edge cases

describePredicate defends against three classes of pathological input so it's safe to call from a UI render path:

  • Cycles. Walked once via a WeakSet. A cycle returns the sentinel "<invalid predicate: cycle>" rather than recursing forever.
  • Depth limit. Inherits MAX_PREDICATE_DEPTH from the predicate validator. Exceeding it logs a dev warning and bails to "always true" () — the predicate validator would have rejected this shape upstream.
  • Non-object input. Strings, numbers, null, and undefined at the root return "<invalid predicate>" — no throw.
  • Unknown operators. A { x: { $weird: 1 } } predicate logs a dev warning and falls through to a generic "x $weird 1" rendering. The validator would reject this at registration; the renderer simply doesn't crash on it.

Why describePredicate (not describe)

The export is describePredicate to avoid colliding with vitest's global describe()describe is the canonical block name for every test file in the monorepo, and import { describe } from "@directive-run/core" would shadow it. describePredicate is the one-token-longer trade we make so test files don't need a rename alias.


Integration patterns

Audit ledger → human prose

Pair with createAuditLedger to turn JSON entries into one-line decisions for a compliance dashboard:

const entries = ledger.forConstraint("canCheckout");
for (const e of entries) {
  if (e.kind === "constraint.evaluate" && e.whenSpec) {
    console.log(
      `${e.active ? "✓" : "✗"} ${describePredicate(e.whenSpec)}`,
    );
  }
}
// ✓ (cartTotal is at least 50) AND (region is one of US, EU)
// ✗ (cartTotal is at least 50) AND (region is one of US, EU)

Rules-diff prose

diffRules emits structural diffs as data; describePredicate turns the before/after halves into prose for PR comments:

const diff = diffRules(before, after);
for (const change of diff.changes) {
  console.log(`${change.constraintId}:`);
  console.log(`  before: ${describePredicate(change.before)}`);
  console.log(`  after:  ${describePredicate(change.after)}`);
}

LLM round-trip

Close the predicateFromIntent loop by showing the human the predicate the model emitted in plain English:

const predicate = await predicateFromIntent({ intent, schema, runner });
const description = describePredicate(predicate, {
  factName: humanizeFactPath,
});
// "Is this the rule you meant? — cart total is at least 50 AND region
// is one of US, EU"

If the user says no, feed the description back in the next prompt turn — the model gets the same plain-English target it was asked to emit.

whenExplain tooltips

In a devtools panel, render the predicate AS PROSE next to the per-clause pass/fail breakdown — keeps the tooltip readable when the JSON is deep:

<Tooltip content={describePredicate(constraint.whenSpec, { factName })}>
  <ConstraintBadge active={constraint.active} />
</Tooltip>

Reference

Previous
doctor.checkAgainst()

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management