Constraints
Retryable semantic assertions for generated output.
Constraints enforce semantic requirements after the model has produced an output candidate. Use a constraint when the output is structurally valid but needs a retry because it fails a business, quality, grounding, or style rule.
import { boundary, constraint } from "@use-crux/core/safety";
export const citeSources = constraint({
id: "cite-sources",
on: boundary.output.text(),
run: async (text) =>
text.includes("[1]")
? { pass: true }
: { pass: false, feedback: "Include at least one citation." },
});Attach constraints globally with createSafetyPlugin(), on prompts and
contexts, or per call with constraints: [...].
Authoring
Every constraint has a stable id, one output boundary, and a
run(subject, ctx) callback. The boundary drives the subject type:
type Answer = {
answer: string;
citations: { title: string; url: string }[];
};
const grounded = constraint({
id: "grounded-answer",
on: boundary.output.object<Answer>(),
maxRetries: 2,
run: async (object) =>
object.citations.length > 0
? { pass: true }
: { pass: false, feedback: "Return at least one source citation." },
});Use boundary.output.path<T>()('field.path') when one structured field owns
the rule:
const titleLength = constraint({
id: "title-length",
on: boundary.output.path<Answer>()("answer"),
run: async (answer) =>
answer.length <= 280
? { pass: true }
: { pass: false, feedback: "Keep the answer under 280 characters." },
});Result shape:
return { pass: true, metadata: { score: 0.96 } };
return { pass: false, feedback: "Explain what must change." };Malformed results fail closed with SafetyResultError, including JavaScript
callers that bypass the TypeScript discriminated union.
Execution
Constraints run through the same per-call Safety session as guardrails. The session applies output guardrails first, so constraints judge the protected candidate, not raw model text. If any assert constraint fails, Crux combines the feedback, asks the model to regenerate, guards the new candidate, and checks constraints again.
Attempt 1 -> schema validates -> output guardrails -> constraints fail
Retry feedback -> regenerate -> output guardrails -> constraints pass
Return guarded final outputAll constraints in a round run concurrently. The model sees all failure feedback at once instead of one retry per constraint.
Severity And Retry Budget
| Option | Default | Meaning |
|---|---|---|
severity: 'assert' | yes | Failure triggers retry and eventually ConstraintViolationError. |
severity: 'suggest' | no | Failure is recorded, but the final output can still return. |
maxRetries | 2 | Per-constraint retry budget. |
constraintMaxRetries | unset | Shared per-call cap across all constraints. |
const formalTone = constraint({
id: "formal-tone",
on: boundary.output.text(),
severity: "suggest",
run: async (text) =>
text.includes("gonna")
? { pass: false, feedback: "Use formal wording." }
: { pass: true },
});
await adapter.generate(prompt, {
input,
constraints: [citeSources, formalTone],
constraintMaxRetries: 3,
});Built-in Strategies
Use constraint.judge(...) to turn a scoring judge into a retryable Safety
constraint:
import { judge } from "@use-crux/core/scoring";
import { boundary, constraint } from "@use-crux/core/safety";
const brandJudge = judge({
id: "brand-voice",
criteria: "Does the answer match our direct, warm brand voice?",
scale: { min: 1, max: 10 },
generate: generateObjectFn,
model,
});
export const brandVoice = constraint({
id: "brand-voice",
on: boundary.output.text(),
run: constraint.judge({ judge: brandJudge, minScore: 7 }),
});Use constraint.citations(...) when the output must cite retrieved or injected
sources. Both helpers carry safe strategy metadata for observability, Devtools,
and Project Index.
Completed operations
transcribe() accepts constraints only at boundary.output.text(). They run
once against the guarded top-level transcript. An assert failure throws;
a suggest failure is retained in result.safety. The provider is not re-called
and constraintMaxRetries is intentionally absent from transcription options.
generateImage() and generateSpeech() do not expose constraints because
their required outputs have no constraint boundary. A typed image prompt that
resolves any constraints fails before provider I/O; remove those constraints
or move the semantic requirement to an ordinary language generation.
Transcription constraints inspect only the guarded top-level transcript text,
never timed details. An enforced rewrite clears segments and words in the
returned canonical result so callers cannot mistake stale timing for guarded
text. See the full
content-primitive matrix.
Report Mode And Tuning
Constraints do not have a mode field on their definition. Use per-call
safety.tune when you want to shadow a constraint:
await adapter.generate(prompt, {
input,
safety: {
tune: {
"brand-voice": { mode: "report" },
},
},
});Report-mode constraints run and audit decisions but do not retry, block, or change output. Unknown tune ids throw.
Streaming
Live streams cannot regenerate earlier text, so constraints run in report mode at end-of-stream. If a constraint must enforce before text is visible, write a guardrail for that streaming boundary instead.
Object and path constraints on streams are final-output checks. Incremental path constraints over partially parsed object streams are tracked as a post-beta roadmap item.
Errors And Audit
ConstraintViolationError is policy-terminal. It carries the failing
constraints, total attempts, last guarded output, and safe Safety decision
metadata. Raw failed output is not exposed by default.
import { ConstraintViolationError } from "@use-crux/core/safety";
try {
await adapter.generate(prompt, { input, constraints: [citeSources] });
} catch (error) {
if (error instanceof ConstraintViolationError) {
error.failedConstraints;
error.decisions[0]?.captured.preview;
}
}Use evaluateConstraint() for focused unit tests. In Evals, prefer
decision-report assertions when the contract you care about is the runtime
Safety decision:
ctx.expect.decisionReport.safety.toHaveOutcome("cite-sources", "retry");