Skip to content
4 min read · 703 words

Writing an Ordering Profile & Family Recipe

In the @nhtio/adk validation battery, an ordering profile is pure declarative data.

You do not write procedural loops over message arrays or imperative timestamp comparisons. You declare what invariant must hold over the conversation timeline using the battery's declarative rule types, and the shared evaluation engine and middleware handle validation, snapshotting, repair, and error reporting.

When adding support for a new model or model generation, determine which tier of work is required:

  • Tier 1: Composing a Family Recipe (the common case) — Add an entry to families.ts composing existing atomic behaviors.
  • Tier 2: Adding an Atomic Behavior Profile — Create a new .ts file in profiles/ when a model has an ordering constraint that maps onto one of the seven rule types but does not yet exist in the catalog.
  • Tier 3: Extensibility Escape Hatches — Pass ad-hoc profiles in consumer code or extend the rule type union.

1. Adding a Family Recipe (The Common Case)

Before authoring a new file, check src/batteries/validation/profiles/families.ts.

Model family recipes represent real-world model deployments (e.g. anthropic-manual-thinking, gemini-3, qwen-3). A family recipe names the combination of atomic behaviors that the target model family requires.

The Recipe Syntax

In src/batteries/validation/profiles/families.ts, add an entry to FAMILY_RECIPES:

ts
export const FAMILY_RECIPES: Record<string, readonly string[]> = {
  // ... existing recipes
  'my-new-model-family': [
    'strict_alternation',
    'openai_shape_baseline',
    'full_history_preservation:thought',
  ],
}

Parameterized Behaviors

Two atomic behavior profiles in the battery are factories that accept parameters via a colon (:) separator in FAMILY_RECIPES:

  1. full_history_preservation:<kind>
    • Parameter: OrderingPrimitiveKind'message' | 'thought' | 'toolCall'.
    • Example: 'full_history_preservation:thought' enforces that historical thoughts are never dropped.
  2. payload_field_preservation:<field>
    • Parameter: dot-path into primitive payload (e.g. signature, clear_thinking, encrypted_content).
    • Example: 'payload_field_preservation:signature' validates that Thought.payload.signature remains unchanged across iterations.

Resolution of these tokens is handled automatically when string profile names are passed into the guard options.

Recipe Authoring Checklist

  1. Naming: Use lowercase kebab-case (<family>-<generation-or-mode>, e.g. deepseek-v4, granite-4-x).
  2. Cite Vendor Sources: Add a JSDoc comment above the entry citing the vendor documentation and the date checked.
  3. Handle Documentation Gaps Honestly: If you are authoring a recipe based on an educated baseline guess rather than a verified spec (as with bytedance-seed or muse-spark), add an explicit /** UNCONFIRMED baseline guess ... */ comment. Never dress up an unverified assumption as certainty — a downstream consumer trusting an unverified recipe in enforce mode in production will discover your guess the hard way.

2. Adding an Atomic Behavior Profile

When a vendor introduces an ordering constraint whose semantics are not yet represented in src/batteries/validation/profiles/, author a new atomic profile.

An atomic behavior file lives in src/batteries/validation/profiles/<behavior_name>.ts and exports an OrderingProfile object.

The Seven Declarative Rule Types

Your profile's rules array contains one or more of the seven discriminated union variants defined in src/batteries/validation/types.ts:

1. OrderRule (type: 'order')

Enforces relative order between two primitive kinds ('message' | 'thought' | 'toolCall').

ts
import type { OrderingProfile } from '../types'

export const thinkingBeforeToolUse: OrderingProfile = {
  name: 'thinking-before-tool-use',
  description:
    'Within the latest assistant turn, thought must precede toolCall.',
  rules: [
    {
      type: 'order',
      id: 'thinking-before-tool-use',
      before: 'thought',
      after: 'toolCall',
      scope: 'adjacent-same-role-group', // or 'entire-turn'
      onlyLatestGroup: true, // evaluate only the active turn
    },
  ],
}

2. RequiredMetadataRule (type: 'requiredMetadata')

Enforces that a primitive carries specific provider metadata in its payload object.

ts
export const thoughtSignatureRequired: OrderingProfile = {
  name: 'thought-signature-required',
  description:
    'The first ToolCall must carry a thoughtSignature in its payload.',
  rules: [
    {
      type: 'requiredMetadata',
      id: 'thought-signature-required',
      kind: 'toolCall',
      applyTo: 'first-in-group', // or 'every'
      requiredPayloadKey: 'thoughtSignature',
      severity: 'blocking', // or 'advisory'
      fallbackPayloadValue: 'skip_thought_signature_validator',
      fallbackReplayCompatibility: 'gemini-thought-signature-sentinel-v1',
    },
  ],
}

3. AlternationRule (type: 'alternation')

Enforces strict turn-to-turn role alternation and optional tool call cardinality bounds.

ts
export const singleToolCallPerTurn: OrderingProfile = {
  name: 'single-tool-call-per-turn',
  description:
    'Strict alternation with at most one ToolCall per assistant group.',
  rules: [
    {
      type: 'alternation',
      id: 'single-tool-call-per-turn',
      roles: ['user', 'assistant'],
      mode: 'strict',
      maxPerGroup: 1, // Caps ToolCalls per turn (e.g. Llama 3)
    },
  ],
}

4. AdjacencyRule (type: 'adjacency')

Enforces that disallowed primitive kinds may not immediately follow a specified primitive. In this ADK, tool execution results live directly on ToolCall, so adjacency directly constrains immediate successors without requiring field correlation.

ts
export const openaiShapeBaseline: OrderingProfile = {
  name: 'openai-shape-baseline',
  description:
    'A Message may not immediately follow a ToolCall; tool results live on ToolCall itself.',
  rules: [
    {
      type: 'adjacency',
      id: 'message-not-immediately-after-tool-call',
      first: 'toolCall',
      disallowBetween: ['message'],
    },
  ],
}

5. PreservationRule (type: 'preservation')

A stateful check that diffs the current dispatch against the previous iteration's snapshot on ctx.stash.

ts
export const reasoningPrunedAfterLatestTurn: OrderingProfile = {
  name: 'reasoning-pruned-after-latest-turn',
  description:
    'Reasoning before latest user turn may drop; recent reasoning must remain unchanged.',
  rules: [
    {
      type: 'preservation',
      id: 'reasoning-pruned-after-latest-turn',
      kind: 'thought',
      // Invariants: 'count-non-decreasing' | 'payload-field-stable' | 'pruned-after-latest-turn'
      invariant: 'pruned-after-latest-turn',
      resetOnModelSwitch: false,
    },
  ],
}

6. RoleRemapRule (type: 'roleRemap')

Validates custom wire role mappings (e.g. IBM Granite split tool roles).

ts
export const roleRemapSplitToolRoles: OrderingProfile = {
  name: 'role-remap-split-tool-roles',
  description: 'IBM Granite 3.x wire role mapping validator.',
  rules: [
    {
      type: 'roleRemap',
      id: 'granite-3-x-split-tool-roles',
      kind: 'toolCall',
      variant: 'granite-3.x',
      expectedRoleTag: 'payload.roleTag',
    },
  ],
}

7. StaleContentAdvisoryRule (type: 'staleContentAdvisory')

Non-blocking hygiene recommendation rule identifying stale content older than the latest user turn.

ts
export const staleThinkingAdvisory: OrderingProfile = {
  name: 'stale-thinking-advisory',
  description:
    'Gemma 4 hygiene rule advising against resending stale thinking.',
  rules: [
    {
      type: 'staleContentAdvisory',
      id: 'stale-thinking-gemma4',
      kind: 'thought',
      scope: 'before-latest-user-turn',
      optOutOptionKey: 'preserveThinking',
    },
  ],
}

3. Extensibility Escape Hatches

OrderingProfile objects are plain JavaScript objects. You do not need to wait for upstream library releases, fork the package, or file an issue to enforce custom constraints or support a newly released model.

Passing Ad-Hoc Profiles Directly

Consumers can pass ad-hoc profile objects directly into profiles: [...]:

ts
import { orderingGuardDispatchMiddleware } from '@nhtio/adk/batteries/validation'
import type { OrderingProfile } from '@nhtio/adk/batteries/validation'

const myCustomProfile: OrderingProfile = {
  name: 'custom-internal-guard',
  description: 'Custom internal pipeline constraints.',
  rules: [
    {
      type: 'order',
      id: 'custom-thought-order',
      before: 'thought',
      after: 'toolCall',
      scope: 'adjacent-same-role-group',
    },
  ],
}

const middleware = orderingGuardDispatchMiddleware({
  profiles: ['nova', myCustomProfile],
  action: 'enforce',
})

See Also