Skip to content

Grade layer — operations guide

Operational reference for users of signalforge.grade. Companion to docs/safety-ops.md, docs/draft-ops.md, docs/prune-ops.md, docs/manifest-loader-ops.md, and docs/warehouse-adapter-ops.md, and to the design record in plans/super/7-quality-grader.md.

The grade layer sits between the prune layer (#6) and the diff renderer (#8). Every drafted artefact that survives prune — column descriptions, column rationales, model description, model rationale, and per-test rationales — is scored by an LLM-as-judge against a configurable rubric through one entry point: signalforge.grade.grade_artifacts. The orchestrator issues one LLM call per (artifact, criterion) pair, parses each response, writes a fail-closed JSONL audit record per decision, and at end-of-run persists a sidecar JSON GradingReport.

This is the load-bearing operationalisation of Architectural Commitment #2 in CLAUDE.mdevaluation in the loop: SignalForge generates AND grades; competitors only generate.

Default posture

The grader is report-only by default. A below-threshold rubric does not fail the run; the operator's diff surfaces the verdict and the operator decides. Operators that want hard-fail-on-threshold behaviour opt in by setting fail_on_below_threshold: true in signalforge.yml (see Threshold-fail behaviour below).

The layer is fail-closed on the audit-write boundary: any I/O error from GradeEvent JSONL persistence or from the end-of-run sidecar write aborts the run as GradeAuditWriteError (DEC-006 / DEC-012, mirrors safety's DEC-011, draft's DEC-006, and prune's DEC-016). The layer is conservative on the per-pair boundary: an LLM retry-exhausted, parser-failed, or budget-exceeded pair lands as a degraded GradingResult(score=None, passed=False, reasoning="…") rather than silently dropped (DEC-015) — the diff renderer flags partial aggregates explicitly so operators don't mistake a degraded-path mean for a real one.

User-facing tagline: every drafted artefact that ships is scored; every scored artefact has a durable receipt; partial runs surface as partial, not silent.

Bias-to-completion posture (grade-completeness)

The library biases toward completion. By default it drives every (artifact, criterion) pair to a score. A <100% / incomplete grade result is legitimate ONLY when the operator intentionally limited cost/time — an explicit max_grade_* ceiling or an explicitly-set total_budget_seconds. Incompleteness must never be a passive by-product of a default guard. The default scaled wall-clock budget survives only as a generous runaway catch; if it ever trips on a default run it FAILS LOUD (via require_complete, below) — it does not silently ship a partial. (#202 DEC-210.)

This posture rests on three always-on mechanisms that, together, take a default run to 100% scored:

  1. A shared, header-honoring rate limiter (#202 US-002/003/004 / DEC-205). The sync/async limiter pair paces dispatch at the provider's advertised rate and honours retry-after / anthropic-ratelimit-* headers, so the original concurrency↔rate-limit collision (which produced ~70 degradations on the full Austin fixture) no longer arises. The default max_retries_429: 6 (#202 DEC-209, raised from 3) is belt-and-braces on top of it.
  2. A bounded transient-recovery sweep (#202 US-005 / DEC-206). After the main concurrent pass, any pair that degraded as transient is re-graded sequentially (concurrency 1 — no second herd), up to sweep_max_rounds (default 3) with a sweep_cooldown_seconds (default 2.0) pause between rounds. A recovered pair is cached like any other success. budget and ceiling degrades are never swept. The whole sweep phase is wall-clock-bounded by sweep_budget_seconds (default 300, #202 QG-FIX-2): on timeout the sweep stops (it does not raise) and any still-transient pairs are left degraded — they then fail loud under require_complete or surface as an honest partial when it is false.
  3. A fail-loud completeness check (#202 US-006/007 / DEC-204). With require_complete: true (the default), grade_artifacts(...) raises GradeIncompleteError (CLI exit 2) when a non-exempt pair is still ungraded after the sweep — see Grade-completeness contract below.

How to opt into a limit (the deliberate, documented act)

A partial grade is only legitimate when the operator chose to cap cost or time. Limiting is therefore an explicit, documented act — set one or more of these knobs in signalforge.yml:

Knob Effect Trips require_complete?
max_grade_calls: <N> Stop scheduling new pairs after N judge calls; rest degrade (ceiling). No — exempt.
max_grade_cost_usd: <X> Stop once accumulated USD ≥ X; rest degrade (ceiling). No — exempt.
max_grade_tokens: <N> Stop once total token movement ≥ N; rest degrade (ceiling). No — exempt.
total_budget_seconds: <S> (explicit int) Absolute wall-clock cap min(scaled, S); on trip rest degrade (budget). No — exempt (a deliberate operator time-ceiling).
grade:
  # Opt into a cost ceiling — a deliberate partial is now legitimate:
  max_grade_cost_usd: 1.50
  # …and/or an explicit absolute wall-clock cap:
  total_budget_seconds: 300

A run that trips one of these opt-in limits is a legitimate partial: the operator asked for it. require_complete does not fire on these degrades (they are exempt).

A default-scaled-budget trip fails loud — never a silent partial

When total_budget_seconds is left at its default None, the engine sizes the wall-clock budget from the work via the scaled formula (budget_base_seconds + budget_per_pair_seconds × ceil(num_pairs / max_concurrent_calls)). This is a runaway catch sized for ample headroom, not a throughput cap — at representative scales (a few hundred pairs at the default max_concurrent_calls: 10) it leaves clear margin over a realistic limiter-paced completion time (pinned by tests/grade/test_engine.py::test_default_scaled_budget_is_non_binding_at_representative_scale). If the default scaled budget ever trips, that is treated as a Stage-1 sizing regression, not a legitimate partial: per DEC-204 the resulting budget degrades trip require_complete and the run fails loud rather than silently shipping a <100% corpus. The remedy is to fix the regression (or, if a partial is genuinely wanted, set total_budget_seconds explicitly to make the cap a deliberate choice) — never to lower the default budget.

Three degrade classes

The completeness contract distinguishes three classes of degrade — they are NOT interchangeable, and only the operator-ceiling class is a legitimate partial:

Class degrade_reason_type What it is Posture
Transient-recoverable "transient" An LLM/network blip, retry-exhaustion, parser failure, or non-clean finish_reason. Retriable on a calmer pass. Swept (US-005) to recover it; any survivor of the sweep is unrecovered and fails loud under require_complete.
Operator-ceiling "ceiling", or "budget" with an explicit total_budget_seconds The operator deliberately capped calls / cost / tokens / time. Legitimate partial. Exempt from require_complete; surfaces via aggregate_complete: false. The ONLY way <100% is acceptable by design.
Unrecoverable "transient" surviving the sweep, OR "budget" on the default scaled budget A failure the recovery machinery could not clear, OR a default-budget overrun (a Stage-1 canary). Fails loud under require_complete (exit 2, named pairs). Never a silent partial.

"Partial is acceptable" is scoped to the operator-ceiling class ONLY. Every other path drives to 100% or fails loud.

Grade-completeness contract (require_complete)

require_complete: bool = True (the default). After the always-on sweep, grade_artifacts(...) raises GradeIncompleteError (tier-2; CLI exit 2) if any non-exempt pair is still ungraded (score=None). The trip / exempt matrix branches on each pair's GradingResult.degrade_reason_type discriminator (never on message text):

  • "transient"always trips (an unrecovered LLM/network failure).
  • "budget" AND total_budget_seconds is None (the default-scaled budget) → trips (a Stage-1 sizing canary — the budget was sized for the work, so the engine is at fault, not an operator ceiling).
  • Exempt — never trips: "ceiling" degrades (an explicit max_grade_* opt-in), and "budget" degrades when total_budget_seconds was set explicitly (a deliberate operator time-ceiling — the curtailed run is the contract, not a surprise).

The raise lands AFTER the fail-closed grade.json sidecar write (so the operator has the complete corpus on disk for diagnosis — mirrors the GradeBelowThresholdError ordering invariant) and BEFORE the fail_on_below_threshold check (incomplete is structural; below-threshold is verdictual). Set require_complete: false in signalforge.yml (or pass --no-require-complete) to revert to the v0.1 report-only posture, where ungraded pairs surface only via aggregate_complete: false. The CLI exposes --require-complete / --no-require-complete (US-007); see docs/cli-ops.md.

Public API

Import from signalforge.grade. The 18 names exported by __all__:

Orchestrator

  • grade_artifacts(model, candidate, prune_result, *, rubric=None, config=None, audit_path=None, sidecar_path=None, client=None, project_dir=None) -> GradingReport — End-to-end orchestrator. Validates the rubric, scans every artefact payload for the prompt-envelope close tag, iterates every (criterion, artefact) pair, issues one LLM-judge call per pair, writes one JSONL audit record per decision, builds the aggregate report, writes the sidecar JSON. Mirrors signalforge.draft.draft_schema and signalforge.prune.prune_tests so the CLI / wrapper layers see one consistent end-to-end shape across pipeline stages. project_dir defaults to Path.cwd(). audit_path defaults to <project_dir>/.signalforge/grade.jsonl. sidecar_path defaults to <project_dir>/.signalforge/grade.json.

Result shapes

  • GradingReport — Aggregate verdict for one model. Frozen Pydantic model with fields grade_schema_version: Literal[1], signalforge_version: str, run_id: str, timestamp: datetime, duration_seconds: float, model_unique_id: str, rubric_hash: str, thresholds: tuple[float, float], results: tuple[GradingResult, ...]. Computed properties: pass_rate, mean_score, aggregate_complete, passed — all derived from results so a GradingReport reconstructed from the sidecar carries identical views to a freshly produced one. Custom __repr__ collapses to identity + aggregate counts (DEC-022 of #6) so accidental _LOGGER.warning("report: %s", report) does not dump multi-paragraph evidence into log sinks.

  • GradingResult — One verdict per (artifact, criterion) pair. Carries artifact_id: str (canonical dotted-path per DEC-009), criterion_id: str, score: float | None (the None sentinel is the DEC-015 degraded path), passed: bool, evidence: str, reasoning: str. Computed property one_line_why returns the first sentence of reasoning capped at 120 characters — the diff renderer (#8) consumes this directly so display logic stays in the data layer. Custom __repr__ omits evidence and reasoning to protect against accidental log dumps.

Configuration

  • GradeConfig — User-facing knobs. Frozen Pydantic model with extra="forbid" (config-shaped per safety-layer.md DEC-015 — typos fail loud). Field reference: see Configuration below.

  • load_grade_config(project_dir, path=None) -> GradeConfig — Loads the grade: block from signalforge.yml. Resolves to <project_dir>/signalforge.yml when path is None. Returns defaults when the file is missing, empty, or the grade: key is absent. Raises GradeConfigError on parse / schema failures. Mirrors load_safety_config / load_draft_config / load_prune_config so the CLI sees one calling convention across stages.

Rubric shapes

  • Criterion — One rubric entry: id: str plus criterion: str (the prompt text sent to the LLM-judge). Frozen, extra="forbid" (DEC-017) so a typo like weight: 1.0 in user-authored rubric YAML fails loud. Both fields required, non-empty, non-whitespace-only.

  • RubricTypeAlias over tuple[Criterion, ...] (DEC-011). Deliberately not a wrapper class; mirrors PruneResult.decisions: tuple[PruneDecision, ...].

  • DEFAULT_RUBRICFinal[Rubric] carrying the four locked criteria from DEC-016 verbatim: clarity, consistency, rationale, no-redundant. The IDs and exact criterion text are load-bearing for rubric_hash reproducibility — every change is a hash change, which means audit records written under v0.1 are no longer reproducible. Bump audit_schema_version before changing any of this in v0.2.

  • GradeThresholds — Per-rubric pass/fail thresholds. min_pass_rate: float = 0.7, min_mean_score: float = 0.5 (defaults match DEC-016). Both bounded [0.0, 1.0] inclusive. The GradingReport.thresholds tuple carries the active values forward into the sidecar.

Audit

  • GradeEvent — One JSONL audit record per LLM-judge call. Constructed ONLY by signalforge.grade.audit._build_grade_event (US-009 AST scan target — sixth of the AST-gated event types). extra="ignore" for forward-compat read-back. See Audit JSONL schema for the field set.

Discriminator literal

  • GradeOutputViolationTypeLiteral["json_parse", "missing_required_field", "missing_criterion_id", "criterion_id_mismatch", "score_out_of_range", "score_not_a_number", "passed_not_a_bool", "unknown_artifact_id", "ambiguous_artifact_id"]. Closed taxonomy carried by GradeOutputError.violation_type so audit-log consumers / orchestrator branches can pattern-match exhaustively rather than sniff message text.

Errors

from signalforge.grade import errors. Every exception subclasses GradeError and carries a class-level default_remediation rendered on a ↳ Remediation: line by __str__.

  • GradeError — Base class. Never raised directly.
  • GradeConfigErrorsignalforge.yml grade: block failed parse or schema validation.
  • GradeRubricError — Rubric YAML structurally invalid (duplicate id, empty rubric, malformed criterion entry).
  • GradeLLMError — One-level adapter wrapping signalforge.llm.LLMError. The original error is preserved on __cause__ and exposed via the cause attribute.
  • GradeBudgetExceededErrorReserved; NOT raised in v0.1. The engine never raises on budget exhaustion: every un-evaluated pair degrades (score=None) and a partial run completes normally with a GradingReport whose aggregate_complete flag is False. The class is reserved for a future hard "the run did nothing" failure (the budget trips before the first pair is graded) — see .claude/rules/grade-layer.md § "Schema-version surfaces".
  • GradePromptEnvelopeBreachError — Artefact payload contained the literal </ARTIFACT> close tag. Refuses to render rather than ship a degraded envelope. Mirrors the drafter's PromptEnvelopeBreachError (#5 DEC-007).
  • GradeOutputError — LLM-judge response failed parse or anchor-contract validation. Carries violation_type: GradeOutputViolationType.
  • GradeAuditWriteError — Fail-closed audit-write failure (OSError / PermissionError / encoding / fsync / symlink containment). Aborts the run; original cause exposed via .cause and __cause__.
  • GradeAuditRecordTooLargeError — Serialised JSONL line (or sidecar JSON document) exceeded the size cap. Raised BEFORE any file is opened so an oversize record leaves no on-disk artefact.

Configuration: signalforge.yml grade: block

Top-level namespace is grade: (claimed per the convention from safety-layer.md DEC-025 / llm-drafter.md DEC-027 / prune-engine.md DEC-020 — every pipeline stage gets one top-level key). Sibling keys (safety:, llm:, prune:, future diff: …) are reserved for other stages and silently ignored by the grade loader.

The full schema (every knob, every default, all v0.1 types). The companion fixture tests/fixtures/grade/example_config.yml (exercised by test_load_grade_config_doc_example_round_trips) pins that the loader accepts a representative grade: block; both this example and the fixture load cleanly through load_grade_config:

# signalforge.yml — grade stage configuration
grade:
  provider: anthropic             # registry-validated; "anthropic" + "openai" + "gemini" are registered (see provider sections below)
  # model: claude-haiku-4-5       # omit to auto-resolve to the provider's default judge (anthropic -> claude-sonnet-4-6); set claude-haiku-4-5 to opt into the faster/stricter Haiku judge
  cache_ttl: 1h                   # Prompt-cache TTL ('5m' or '1h')
  max_output_tokens: 1024         # Per-criterion JSON response cap (default 1024)
  max_retries_429: 6              # Rate-limit (429) retry budget; default 6 (#202)
  max_retries_5xx: 1
  max_retries_conn: 1
  budget_base_seconds: 60         # scaled-budget constant term (#198)
  budget_per_pair_seconds: 20.0   # scaled-budget per concurrency-wave allowance (#198)
  # total_budget_seconds: 300     # OPTIONAL absolute hard cap; omit (default None) to use the scaled formula alone, set to cap via min(scaled, this)
  # max_grade_calls: 500          # opt-in soft ceiling: stop scheduling new pairs after N judge calls (rest degrade)
  # max_grade_cost_usd: 1.50      # opt-in soft ceiling: stop once accumulated USD meets/exceeds this (rest degrade)
  # max_grade_tokens: 2000000     # opt-in soft ceiling: stop once total token movement meets/exceeds this (rest degrade)
  max_concurrent_calls: 10        # In-flight LLM calls (range [1, 100]); 1 = v0.1 sequential
  min_pass_rate: 0.7              # Aggregate threshold: fraction of passed criteria
  min_mean_score: 0.5             # Aggregate threshold: mean score across criteria
  fail_on_below_threshold: false  # opt-in hard-fail; default report-only
  cache_enabled: false            # Per-pair grade cache; OFF by default (#197) — opt in for pinned-candidate re-grades only
  # rubric:                       # Optional override; omitted = use DEFAULT_RUBRIC
  #   - id: clarity
  #     criterion: "..."

A minimal signalforge.yml is just grade: {} (or no grade: key at all) — every field has a locked default from DEC-023..DEC-027 and the loader returns GradeConfig() silently. A customised example that overrides the rubric:

grade:
  model: claude-sonnet-4-6
  total_budget_seconds: 600
  min_pass_rate: 0.8
  rubric:
    - id: clarity
      criterion: >
        Is the column description clear, specific, and actionable?
    - id: precision
      criterion: >
        Does the description state exactly what is captured (units,
        timezone, encoding) without hand-waving?
    - id: jargon
      criterion: >
        Is the description free of acronyms or domain-jargon a
        downstream analyst could not look up in five seconds?

Field-by-field:

  • provider — The LLM provider strategy name (issue #135 DEC-007), resolved against the signalforge.llm.providers registry and threaded into call_llm from the per-criterion judge call, independently of the drafter's DraftConfig.provider. Default "anthropic". An unknown value fails loud at config-load, listing the registered provider names. Deliberately a registry-validated str, not a Literal — the provider registry is a forward-looking plugin point. Today anthropic, openai, and gemini are registered; see OpenAI provider and Gemini provider below for the non-default options.
  • model — The model id used by every per-pair judge call. Default resolves per-provider at config-load (#187): when model: is omitted, the loader injects the calling provider's default judge model from signalforge.llm.providers.PROVIDER_DEFAULT_MODELSanthropicclaude-sonnet-4-6, openaigpt-4o-mini, geminigemini-2.5-flash. Anthropic defaults to Sonnet: the #187 calibration gate found claude-haiku-4-5 grades the rubric stricter than Sonnet (~77–82% concordance, below the 85% bar — see docs/research/187-haiku-calibration.md), so Haiku is an explicit opt-in (grade.model: claude-haiku-4-5), not the default. An explicit model: is honoured verbatim. A SKU-prefix/provider mismatch (e.g. provider: openai with a claude- model) fails loud at config-load via the model↔provider compat validator (reusing signalforge.llm.providers.PROVIDER_SKU_PREFIXES).
  • cache_ttlLiteral["5m", "1h"]. Default "1h" (vs. the drafter's "5m") because 60 sequential per-criterion calls under retry backoff can stretch beyond a 5-minute window; "1h" gives margin at no extra cost (cache writes are one-shot regardless of TTL).
  • max_output_tokens — Per-criterion judge response cap. Default 1024 (#187 — raised from 256 to substantially reduce truncation risk for a verbose one-line gemini-2.5-flash grade JSON; the expected JSON response is still ~150 tokens, so the larger ceiling costs nothing on the happy path). 1024 reduces but does not fully eliminate Gemini truncation at scale — see the per-provider floors below; Gemini-heavy runs may want 4096. Independent of DraftConfig.max_output_tokens.
  • max_retries_429 / max_retries_5xx / max_retries_conn — Per-call retry budgets at the centralised, provider-neutral signalforge.llm.call_llm / call_llm_async seam (#5 DEC-012; #135 DEC-005). Defaults 6 / 1 / 1. max_retries_429 was raised 3 → 6 in #202 (DEC-209) as belt-and-braces over the primary 429 fix — the #202 shared, header-honoring rate limiter (DEC-205), which paces dispatch at the provider's advertised rate and honours retry-after / anthropic-ratelimit-* headers so the grader rarely consumes a retry under normal load. The wider 429 budget gives the always-on transient-recovery sweep (#202 US-005) more headroom to drive every pair to a score before the fail-loud require_complete check fires, consistent with the bias-to-completion posture. Dial down (e.g. max_retries_429: 0) for an aggressive batch posture where one retry-exhaustion is preferable to dozens of stalled calls. The grade defaults are independent of the seam's own keyword defaults (which still serve the drafter via DraftConfig).
  • budget_base_seconds — Fixed constant term in the scaled wall-clock formula (issue #198, default 60). Covers per-run setup (config resolution, cache priming, the first concurrency wave's ramp) that does not scale with the number of pairs. Must be positive.
  • budget_per_pair_seconds — Per concurrency-wave wall allowance in the scaled formula (issue #198, default 20.0). The formula multiplies this by ceil(num_pairs / max_concurrent_calls) — the number of concurrency waves, not the raw pair count — so it is the wall-clock allowance per wave of max_concurrent_calls in-flight judge calls. The default is grounded in the #179 baseline (Sonnet judge p50 ~10s/call; 220 pairs at concurrency 10 → 60 + 20.0 × ceil(220/10) = 500s against a measured 222.9s — ~2.25× headroom). It is a runaway backstop sized to tolerate 429 retry storms, NOT a completion target; the ticket-literal 2.0 would compute 104s and degrade ~half the pairs, recreating the failure this scaling fixes. Must be positive.
  • total_budget_secondsOptional absolute hard ceiling on the whole-run wall-clock budget (issue #198 DEC-001; reinterpreted from the flat pre-#198 default of 300). Default None. When None, the engine sizes the budget from the work via the scaled formula effective = budget_base_seconds + budget_per_pair_seconds × ceil(num_pairs / max_concurrent_calls) — a backstop that grows with model width and concurrency rather than a flat 300s the pre-#186 sequential era was sized for. When set to an int, the effective budget is min(scaled, total_budget_seconds) — i.e. an explicit value still acts as a hard cap on top of the scaled estimate, preserving exact v0.1 absolute-cap semantics for pinned signalforge.yml files (an operator who set total_budget_seconds: 600 keeps that 600s ceiling). Mirrors PruneConfig.total_budget_seconds degrade semantics: when the budget trips, every remaining (artefact, criterion) pair lands as a degraded GradingResult(score=None) rather than silently dropped (DEC-015). Under the asyncio orchestrator (issue #186) the budget is enforced via asyncio.timeout(effective) wrapping the TaskGroup; on trip, un-completed pairs are filled in by a synthesis pass with reasoning="grade budget exceeded ({effective}s) before evaluation". Tests inject deterministic timing via the module-level _async_sleep alias (mirrors the _sleep injection pattern from llm-drafter.md DEC-004).
  • max_grade_calls / max_grade_cost_usd / max_grade_tokens — Three opt-in soft ceilings on the grade run (issue #198 DEC-002). All default None (off). When set, whichever ceiling trips first stops scheduling new (artefact, criterion) pairs; the remaining pairs DEGRADE (score=None, never raise — mirroring the DEC-015 conservative-degrade contract) with a reasoning string naming the tripped ceiling. Accounting: max_grade_calls counts LLM judge calls only (cache hits make no call → never counted); max_grade_cost_usd accumulates the full per-call USD incl. cache-read/write economics, computed from per-call token usage via signalforge.llm.pricing; max_grade_tokens accumulates all token movement (input + output + cache-creation + cache-read). Each must be positive when set. Soft / best-effort overshoot: because every pair is dispatched into the one TaskGroup and cost/tokens are known only after a call returns, the cost/token ceilings stop only un-started pairs — up to max_concurrent_calls − 1 in-flight calls may complete past the threshold. max_grade_calls is near-hard: it reserves a dispatch slot (increments a shared counter immediately, before the LLM await) so it stops at most one call over the limit in practice.
  • max_concurrent_calls — Number of in-flight (artifact × criterion) LLM calls allowed concurrently (issue #186). Default 10 matches the typical Anthropic-tier throughput sweet-spot; bounded [1, 100] with @field_validator rejecting < 1 or > 100 at config-load. Setting 1 yields v0.1 sequential behaviour bit-for-bit (semaphore-of-1 serialises in dispatch order, preserving (criterion, artifact) JSONL ordering). Under concurrent dispatch the audit JSONL lands in arrival order (audit_schema_version unchanged at Literal[1]); the tests/grade/_helpers.py::_sort_grade_events(lines) helper restores deterministic ordering for tests that snapshot the file. CLI does not expose a --max-concurrent-calls flag (mirrors min_pass_rate / min_mean_score config-file-only convention).
  • min_pass_rate — Floor on the fraction of (artefact, criterion) pairs that scored passed=True for the rubric to count as passed overall. Default 0.7. Bounded [0.0, 1.0]. Mirrors GradeThresholds.min_pass_rate.
  • min_mean_score — Floor on the mean numeric score across non-null verdicts. Default 0.5. Bounded [0.0, 1.0]. Mirrors GradeThresholds.min_mean_score.
  • fail_on_below_threshold — Hard-fail switch for the aggregate threshold check. Default false — v0.1 ships report-only posture by default. When true, grade_artifacts(...) raises GradeBelowThresholdError once the aggregate GradingReport.passed is False (pass_rate < min_pass_rate and/or mean_score < min_mean_score). The raise lands AFTER the sidecar JSON is durably persisted so the operator has a complete grade.json for diagnosis. See Threshold-fail behaviour below for the full ordering invariant. Graduated from v0.2 reservation to v0.1 wiring in #9 (US-002).
  • cache_enabled — Master switch for the per-(artifact, criterion) grade cache (issue #189 DEC-016; default flipped to false by issue #197). The cache is cross-invocation only (read in the orchestrator's sync prefix before any write of the current run, so it never reuses work within one run — intra-run speed is the #186 asyncio fan-out, not the cache) and its key mixes a hash of the drafted artefact text. Because the drafter is a live, non-deterministic LLM, a full signalforge generate re-run rotates that hash and misses on every pair (measured: 370 entries written, 0 read back — docs/research/179-runtime-benchmark.md). Left on by default it silently wrote hundreds of never-hit .signalforge/grade-cache/*.json files and implied a "re-run is fast" UX the architecture can't deliver, so it now defaults false. The keying itself is correct (changed text should re-grade), so the cache stays in the code; set cache_enabled: true to opt in on the narrow cross-run paths where artefact text is identical — re-grading a pinned/committed candidate in CI, a --no-grade draft-then-grade flow, or a resumed grade over an unchanged draft. When true, the content-addressed lookup + write run on every pair (the five-part key invalidates by construction — no TTL knob). The CLI's signalforge generate --no-cache flag forces this off on a per-run copy (the on-disk signalforge.yml is unaffected); with the default now false the flag is a no-op unless config opted in. extra="forbid" makes a typo like cache_enable: (missing the trailing d) fail loud at config-load.
  • rubric — Optional rubric override. None (the default) means the orchestrator falls back to DEFAULT_RUBRIC. When provided, must be a non-empty list of mappings, each with non-empty id and criterion strings; duplicate id values raise GradeRubricError. Override is wholesale, not merge.

Unknown keys under grade: raise GradeConfigError (Pydantic extra="forbid"). Typos like mdoel: or total_budget_secnds: fail loud at load time rather than silently no-op'ing.

Grade cache

The grade layer ships a persistent, content-addressed cache for per-(artefact, criterion) verdicts (issue #189). On a cache hit the LLM judge is not called — the prior verdict is reconstructed into a GradingResult and audit-logged with cache_hit: true on the corresponding GradeEvent.

Default OFF as of issue #197. The cache is cross-invocation only — every lookup runs in the orchestrator's sync prefix before any write of the current run, so it never reuses work within a single grade run (intra-run speed is the #186 asyncio fan-out, not the cache). Its key mixes a hash of the drafted artefact text, and the drafter is a live, non-deterministic LLM — so a full signalforge generate re-run rotates the key and misses on every pair (measured: 370 entries written, 0 read back — docs/research/179-runtime-benchmark.md). It therefore does not make generate re-runs faster. The keying is nonetheless correct (changed text should re-grade), so the cache stays in the code and is opt-in (grade.cache_enabled: true) for the narrow cross-run paths where the candidate text is genuinely identical — see When to expect cache hits. A future "fast re-run" UX needs a draft cache to feed identical text in; this grade cache is the already-correct second half of that.

Cache layout

<project_dir>/.signalforge/grade-cache/<cache_key>.json

where <cache_key> is a 16-hex blake2b-8 digest. Single-level flat directory; one file per (artefact, criterion) verdict. File mode 0o600 (owner-only read/write) at os.open time — mirrors every other fail-closed writer in the project. Cache files may quote LLM-emitted evidence / reasoning text, so the mode is load-bearing for PII posture.

A CacheRecord JSON document (the on-disk shape) duplicates the fields a reader needs without wrapping a nested GradingResult — operator UX favours jq '.score' cache.json over jq '.result.score'. See signalforge.grade.cache.CacheRecord for the exact field set (DEC-011 of #189).

Five-part cache key recipe

The cache key is invalidated by any change to the five inputs that genuinely determine the verdict:

cache_key = blake2b(
    criterion_prompt_hash    + "\x00" +   # changes when criterion text changes
    artifact_text_hash       + "\x00" +   # changes when artefact text changes
    provider                 + "\x00" +   # changes on provider swap (anthropic / openai / gemini)
    model                    + "\x00" +   # changes on model SKU swap (claude-sonnet-4-6 -> claude-haiku-4-5, etc.)
    prompt_version_template,              # changes when system prompt / rubric list / envelope tags change
    digest_size=8,
).hexdigest()  # 16 hex chars

NUL-byte separators prevent id/text concatenation collisions (mirrors the existing criterion_prompt_hash recipe). Five clean invalidation axes — no implicit TTL, no time-based eviction. If a change should invalidate a prior verdict, it lives in the key; if it does not live in the key, it should not invalidate the verdict.

(Full recipe + every contributing source: DEC-004 of #189.)

grade.cache_enabled knob

signalforge.yml carries one knob:

grade:
  cache_enabled: false   # default (#197); set true to opt in for pinned-candidate re-grades

Default false (#197). With the default, every grade pair routes through the live LLM judge call and no .signalforge/grade-cache/*.json files are written. Set true only on the narrow cross-run paths where the candidate text is identical across runs (see When to expect cache hits); on the common generate re-run path the cache is pure overhead (writes that are never read). The on-disk cache files are not deleted by flipping the knob; an operator wanting to wipe them runs signalforge cache clear --grade (see docs/cli-ops.md).

The CLI's signalforge generate --no-cache flag forces this knob off on a per-run copy of the resolved config — the on-disk signalforge.yml is unaffected. With the default now false the flag is a no-op unless the operator has opted in via config; it remains useful to force a single run cold when cache_enabled: true is committed.

extra="forbid" makes typos like cache_enable: (missing the d) fail loud at config-load — silent no-op would defeat the gate.

Degraded results never land in the cache

Per the conservative score-and-degrade taxonomy (DEC-007 of #189, mirrors grade-layer.md § DEC-015), a degraded verdict (score=None — LLM retry exhausted, parser failure, envelope-breach, budget exceeded) is never written to the cache. Caching that record would silently replay the failure forever, preventing recovery from a transient LLM / network blip. Cache writes are gated on result.score is not None.

Cache reads never construct a degraded GradingResult either — a malformed on-disk cache file (e.g. a score: null injected by an attacker or a corrupted record) routes to a cache miss + INFO log (grade cache validation failed, grade cache malformed json, grade cache read failed, or grade cache key mismatch depending on the failure mode). The live LLM judge runs and re-populates the entry. INFO (not WARNING) because cache miss is a normal non-actionable outcome — --quiet raises the floor to WARNING and correctly suppresses these.

A key-recomputation gate (added by #189 QG Pass 1 Finding 1) defends against cache poisoning: lookup_cache recomputes the 5-part cache key from the loaded record's stored hashes and compares to the lookup key. On mismatch (a hostile or corrupt file whose body lies about its forensic hashes), the read silently misses with grade cache key mismatch INFO + a key / recomputed payload, so the next run writes a canonical record on top.

signalforge cache clear --grade subcommand

Removes <project_dir>/.signalforge/grade-cache/ recursively. Symlink-hardened, idempotent on a missing directory, exits 0 on success. Documented in docs/cli-ops.md with the full operator-facing behaviour, exit codes, and rationale for the absence of a --confirm flag. (DEC-015 of #189.)

Future siblings (cache clear --drafter, cache stats, cache list) are out of scope for #189; the nested-subcommand shape reserves namespace for them.

When to expect cache hits

Only when the same artefact text is graded again under the same rubric / provider / SKU / prompt version. With a live drafter that means a re-run that does not re-draft:

  • Re-grading a pinned / committed candidate (e.g. a CI step that reads a frozen candidate from disk and grades it, instead of re-drafting via the LLM).
  • A --no-grade draft-once-then-grade-separately flow where the drafted candidate is captured and a later, separate grade pass runs over that identical text.
  • A resumed / re-attempted grade over an unchanged draft (same candidate object, partial grade re-run).

A plain signalforge generate <model> re-run is not in this list: it re-drafts via the live LLM, so the artefact text — and thus artefact_text_hash — differs every run, missing on every pair. This is the #197 finding; do not expect a "re-run is fast" speed-up from this cache.

When to expect cache misses

  • Rubric criterion text changed (criterion_prompt_hash flips).
  • System prompt / rubric list / envelope tags changed (prompt_version_template flips — bumped in lockstep when the grade _SYSTEM_PROMPT or any DEFAULT_RUBRIC criterion text changes; see Reproducibility hash fields).
  • Provider or model swapped. provider: openaiprovider: anthropic; model: claude-sonnet-4-6model: claude-haiku-4-5. Each combination scopes its own cache entries.
  • Artefact text actually changed. Drafter rewrote a column description, prune dropped a test (changing which tests reach grade), etc.

Threshold-fail behaviour

Default posture is report-only — grade_artifacts(...) always returns a GradingReport, and the operator inspects report.passed / report.pass_rate / report.mean_score to decide what to do with the verdict. Setting fail_on_below_threshold: true in signalforge.yml opts the run into hard-fail behaviour: when the aggregate report does not meet both min_pass_rate AND min_mean_score, grade_artifacts(...) raises GradeBelowThresholdError instead of returning the report.

The raise lands after the fail-closed sidecar write (write_grading_report(...)) and the per-pair JSONL audit are durably on disk, and before grade_artifacts(...) returns. Order is load-bearing (DEC-021):

  1. Iterate every (criterion, artefact) pair → write one grade.jsonl line per decision.
  2. Build the aggregate GradingReport from the per-pair results.
  3. Write the grade.json sidecar (fail-closed; O_TRUNC overwrite).
  4. Emit the single INFO log per invocation (grade completed: …).
  5. If fail_on_below_threshold=True AND report.passed=False, raise GradeBelowThresholdError carrying pass_rate, mean_score, min_pass_rate, min_mean_score, aggregate_complete.
  6. Otherwise return the report.

Pinned by tests/grade/test_engine.py::test_grade_below_threshold_writes_sidecar_before_raising — a threshold-fail run leaves a complete grade.json on disk so the operator can diagnose why the run fell below threshold (which criterion failed, which artefact's score dragged the mean down, the full evidence/reasoning text). Raising before the sidecar would defeat the durable hand-off; the test catches the raise then asserts the sidecar exists and round-trips through GradingReport.model_validate_json.

GradeBelowThresholdError carries the five aggregate fields so a caller catching the error can render a diagnostic without reaching back to the report:

from signalforge.grade import GradeBelowThresholdError, grade_artifacts

try:
    report = grade_artifacts(
        model, candidate, prune_result,
        config=load_grade_config(project_dir),
        project_dir=project_dir,
    )
except GradeBelowThresholdError as exc:
    # Sidecar JSON is on disk at <project_dir>/.signalforge/grade.json.
    log.error(
        "grade below threshold: pass_rate=%.3f (min %.3f), mean_score=%.3f (min %.3f)",
        exc.pass_rate, exc.min_pass_rate, exc.mean_score, exc.min_mean_score,
    )
    sys.exit(2)

The CLI (#9) wires the raise into its INPUT exit-code tier (exit 2); see docs/cli-ops.md for the full exit-code table once US-009 lands.

Concurrency (asyncio orchestrator)

Issue #186 graduated the grade layer from sequential per-(artifact × criterion) LLM calls to an asyncio.TaskGroup-orchestrated concurrent dispatch with a configurable cap. Default max_concurrent_calls = 10 cuts grade wall-clock from ~280 s sequential to ~30 s concurrent on a typical ~280-pair model (~70 artifacts × 4 default criteria) — measured ~9× speedup, close to the Amdahl ceiling at concurrency=10 (the small synchronous prefix + per-call tail latencies leave a few seconds of irreducible serial work). Operators tune via signalforge.yml; setting 1 reverts to v0.1 sequential behaviour bit-for-bit.

The public grade_artifacts(...) signature is unchanged — sync prefix → asyncio.run(_grade_artifacts_async_core(...)) → sync suffix. From the caller's perspective the grade layer still looks synchronous; the concurrency lives entirely inside.

Typed errors at orchestrator entry

Two pre-flight guards run BEFORE asyncio.run, both fail loud:

  • GradeNestedEventLoopError (CLI tier 1) raises if grade_artifacts(...) is called from within a running event loop. The v0.3 grader is single-event-loop only — wrapping in an outer event loop (e.g. for cross-model batch parallelism) is a v0.4 follow-up. Remediation: "v0.3 grade_artifacts is single-event-loop only. Call before entering an event loop, or wait for v0.4 async sibling."
  • LLMProviderAsyncUnsupportedError (CLI tier 3) raises if the configured provider's supports_async is False, regardless of max_concurrent_calls (the grade engine consumes call_llm_async exclusively post-#186, so cap=1 is NOT an escape hatch — every per-pair call would degrade to GradeLLMError silently). All three v0.3 providers (Anthropic, OpenAI, Gemini) set supports_async = True; this guard exists for v0.4+ providers that may ship sync-only. Remediation: "Pick an async-capable provider for grading (Anthropic / OpenAI / Gemini all support async)." — fail loud rather than silent-clamp (mirrors the project's extra="forbid" posture).

Cost expectations under concurrency

Anthropic prompt caching pays the cache-write premium on the first call and the read discount on subsequent calls. Under concurrent dispatch, calls 1..N start before any response returns, so each of the first max_concurrent_calls calls pays the write premium (~1.25× input cost on the cached rubric block) instead of the read discount (~0.10×). For the default cap of 10 and the ~445-token rubric block, that's ~4 450 extra input-token-equivalents per model run — absolute cost ~$0.003–$0.005 per typical model. Operators cost-sensitive enough to care can set grade.max_concurrent_calls: 1 to recover the v0.1 cost profile (trading off ~9× wall-clock reduction). OpenAI and Gemini do not support prompt caching, so concurrent dispatch carries no additional cost penalty on those providers.

Concurrent-append atomicity assumption

The fail-closed audit writer (write_grade_event) caps individual JSONL records at _GRADE_AUDIT_RECORD_LIMIT_BYTES = 4000. POSIX guarantees that an O_APPEND write(2) syscall atomically seeks to end-of-file and writes — concurrent appenders never overwrite each other's bytes within a single syscall. Caveat: unlike the well-known PIPE_BUF guarantee (which applies strictly to pipes/FIFOs, not regular files), POSIX does NOT specify a per-write atomic-byte ceiling for regular-file writes. In practice on Linux, the kernel writes a small buffer (≤ 4 000 bytes ≪ typical page size) in one syscall — but the write() syscall is permitted to return short, and our short-write loop (while written < len(encoded): n = os.write(fd, encoded[written:])) handles that by issuing additional write() calls. If a short write occurs mid-record, a concurrent appender's record can interleave between the two writes. The probability is low for small (< 4 KiB) records on Linux's ext4 / btrfs / xfs, but it is not zero. SignalForge's CI matrix is Linux-only; users who need stricter byte-level atomicity guarantees (and the macOS/BSD users where the same caveat applies) should set max_concurrent_calls: 1 (which serialises writes by construction).

JSONL arrival ordering

Under concurrent dispatch, .signalforge/grade.jsonl lands in arrival order, not the (criterion, artifact) iteration order from the sequential path. The record shape is unchanged (audit_schema_version still Literal[1]); external sidecar consumers that need stable order should sort by (artifact_id, criterion_id) post-load (the SignalForge test suite uses tests/grade/_helpers.py::_sort_grade_events(...) for the same purpose). Setting max_concurrent_calls: 1 preserves v0.1 ordering for byte-identity workflows.

Decision matrix

Per-pair scoring is a [0.0, 1.0] float plus an explicit passed: bool. The judge prompt (US-005) instructs the model to emit both — the score is the granular signal, and passed is the model's own pass/fail call against the criterion's intent. The diff renderer (#8) rescales the float to a 0–5-star display at render time; the data layer stays in clauditor shape (DEC-002).

Verdict shape score passed What it means Display in diff (#8)
Strong pass 0.81.0 True Artefact meets the criterion clearly. 4–5 stars.
Weak pass 0.50.79 True Meets the criterion with caveats; reasoning calls them out. 2–3 stars.
Weak fail 0.20.49 False Falls short; the artefact ships only because it survived prune. 1–2 stars.
Strong fail 0.00.19 False Material problem; reviewer should rewrite or remove. 0–1 star.
Degraded null False Could not evaluate (LLM retry exhausted, parser failed, total budget tripped). DEC-015 sentinel. "—" (no stars); aggregate_complete: false flag fires.

Aggregate semantics (DEC-002 — clauditor's threshold-AND pattern). The GradingReport.passed computed field is True iff both:

  • pass_rate >= thresholds[0] (default 0.7) — pass_rate is the mean of passed over results with a non-null score.
  • mean_score >= thresholds[1] (default 0.5) — mean_score is the mean of score over results with a non-null score.

AND, not OR. A rubric where every criterion is a soft pass at score=0.55 averages well above the mean-score floor but might still fail the pass-rate floor (if those 0.55 scores came in as passed=False). The two thresholds defend two different failure modes: min_pass_rate catches "a few catastrophic failures masked by many soft passes"; min_mean_score catches "many tepid passes that cluster just above the bool boundary."

Degraded-path skip (DEC-015). Both aggregate computations skip results where score is None. A criterion's retry-exhaustion does not silently lower the pass_rate of the criteria that did run successfully — but aggregate_complete flips to False, so the diff renderer flags the report as partial.

Row-count calibration

The sixth test variant, row_count_between (issue #169; see docs/draft-ops.md and docs/prune-ops.md), carries two numeric bounds that an LLM can satisfy trivially. A drafted row_count_between(minimum=0, maximum=None) passes every Pydantic check (at least one bound is set), runs against the warehouse, and always drops as always-passes — the bound is so loose nothing can violate it. The fully-vacuous case is therefore caught by prune, not the grader — the failing-rows CTE's WHERE n < 0 predicate matches nothing, failures=0, and the test routes to always-passes before any grade call.

The grader's calibration value applies to bounds that survive prune because they were violated: a minimum=1, maximum=None on a model where the table happens to be empty (kept — caught the empty-table case), or a minimum=10000, maximum=20000 on a 5K-row table (kept — real out-of-bounds signal). For those kept tests the prune layer can't distinguish "the LLM picked minimum=1 thoughtfully because the rollup truly should have at least one row per day" from "the LLM picked the lowest valid non-vacuous number to satisfy at least one bound." That distinction is what the calibration prose teaches the judge to score.

Where the calibration lives. As of #169, the existing no-redundant criterion in DEFAULT_RUBRIC carries language scoring whether a numeric bound is a meaningful guardrail vs. a vacuous one:

Are any tests redundant — semantically identical to another test, already dropped by the prune layer as always-passing, or trivially satisfiable? For tests carrying numeric bounds (e.g. row_count_between), is each bound a meaningful guardrail calibrated to the model's expected size, rather than a vacuous floor or ceiling (minimum=0 with no maximum, or a maximum so high it cannot fire)?

The criterion was extended rather than added as a fifth — a 5th criterion would have cost ~25% more LLM round-trips per artifact without adding load-bearing signal. "Trivially satisfiable" already covered the conceptual territory; the extension makes it concrete for the bound shape. Locked verbatim per DEC-016 of #7; rotation history is recorded in src/signalforge/grade/rubric.py.

Routing. A borderline-calibrated bound that survived prune is low-signal, not a degrade trigger. The judge scores the artifact normally, the score lands low (typically 0.00.2), passed flips to False, and the diff renderer routes the row to flagged — the operator sees the test ships but the calibration is suspect. It does not route to kept-uncertain (that tier is for prune-side "could not evaluate" origins, not grade-side weakness) and it does not route through the conservative degrade path (that path is the DEC-015 sentinel for score=None). Note: this routing applies to kept row_count_between tests where the bound was violated; a fully vacuous minimum=0, maximum=None would already be always-passes / dropped at the prune layer and never reach the grader.

The 3-trigger degrade taxonomy stays locked. A vacuous bound is a real grade, not a degraded one. The three causes for score=None, passed=False, reasoning="..." are unchanged:

  1. LLMError retries exhausted (including a provider-specific safety- filter / no-content response routed via LLMResponseFormatError).
  2. GradeOutputError (parser failure or anchor-contract failure).
  3. The effective wall-clock budget (scaled formula, optionally capped by total_budget_seconds) exceeded.

(The opt-in max_grade_calls / max_grade_cost_usd / max_grade_tokens ceilings — issue #198 — also degrade un-started pairs, but they are a separate operator-chosen surface, not a fourth automatic trigger; they degrade with a reasoning naming the tripped ceiling and emit a distinct grade ceiling exceeded WARNING — see Debugging.)

A fourth trigger for "vacuous bound" would conflate "we could not evaluate" with "we evaluated and the result was weak" — two different operator-actions. The score-and-pass field already carries the weak verdict; adding a degrade slot would hide it.

_PROMPT_VERSION rotation. The criterion-text change rotates the grade-side _PROMPT_VERSION and the grade-prompt cache-stability snapshot moves in lockstep (US-009 of #169). This is distinct from the drafter-side _PROMPT_VERSION — the two cache prefixes are independent.

Composite-key calibration (unique_combination)

As of issue #170, the existing no-redundant criterion extends to the seventh test variant, unique_combination (see docs/draft-ops.md and docs/drafter-catalogue.md). The criterion text now scores whether a composite-key tuple is a meaningful grain (e.g. (order_id, line_item_id) on an order-line table) vs. a vacuously-unique shape like (primary_key, anything) — the latter is always unique by construction because the primary key alone guarantees it, so the test adds no signal beyond the existing single-column unique test.

The routing is the same as row_count_between vacuous-bound calibration: a borderline-meaningful tuple that survived prune (because it caught real duplicates) is scored low (0.00.2), passed flips to False, and the diff renderer routes the row to flagged — the operator sees the test ships but the grain is suspect. The strictly vacuous shape (a tuple containing a known unique column) is caught by the prune layer as always-passes before reaching the grader; the calibration value applies to the borderline cases prune cannot dismiss.

The criterion was extended rather than added as a fifth — keeping the rubric at four criteria avoids the ~25% per-artifact round-trip cost a new criterion would introduce. "Trivially satisfiable" already covered the conceptual territory; the extension makes it concrete for both the numeric-bound shape (row_count_between) AND the composite-key shape (unique_combination). Locked verbatim per DEC-007 of #170 (extending DEC-016 of #7); rotation history is recorded in src/signalforge/grade/rubric.py. The grade-side _PROMPT_VERSION rotated under #170 (US-008 + US-009) in lockstep with the criterion-text change.

Audit JSONL schema

Consumer guide. For cross-stage joins (including grade JSONL ↔ grade sidecar ↔ diff sidecar on artifact_id), jq / pandas worked examples, the forward-compat policy, and the redaction surface, see docs/audits.md. This section is the grade-layer production contract.

Every per-pair GradingResult produces exactly one JSONL record at audit_path (default <project_dir>/.signalforge/grade.jsonl). One record per line; atomic concurrent appends via O_APPEND | O_CREAT | 0o600 and a single os.write (looped on short returns) followed by os.fsync (DEC-006). The fourth instance of the convention across the codebase — mirrors signalforge.safety.audit, signalforge.draft.audit, and signalforge.prune.audit.

GradeEvent fields (~19 total):

Field Type Meaning
audit_schema_version integer (int) Audit shape version. Currently 3 (widened Literal[1]int and bumped 1 → 2 in #189 for cache_hit, 2 → 3 in #202 for degrade_reason_type). Bump only on shape change; extra="ignore" handles additions.
signalforge_version PEP-440 version string Package version that produced the record.
run_id 32-hex-char string Single uuid4().hex per grade_artifacts invocation (DEC-020). Repeated on every JSONL record AND on the sidecar so JSONL → sidecar correlation never depends on timestamp ranges.
timestamp ISO-8601 UTC datetime When the per-call decision was finalised. Distinct from the sidecar's started_at.
model_unique_id string dbt unique_id of the graded model.
artifact_id string (canonical dotted-path) DEC-009 canonical shape — see Artefact-id format below.
criterion_id string The Criterion.id the judge scored against. Stable across artefacts of one run.
score float [0.0, 1.0] or null Numeric verdict. null is the DEC-015 degraded sentinel.
passed bool The judge's own pass/fail call. False for every degraded record by construction.
evidence string The judge's quoted-fragment evidence pulled from the artefact text. Empty for degraded records.
reasoning string The judge's free-text rationale. Empty for degraded records other than the leading "call failed" / "grade budget exceeded" descriptor.
degrade_reason_type "transient" / "budget" / "ceiling" / null Structured degrade discriminator (#202). null for a scored record; one of the three literals for a degraded record. Set centrally in _build_degraded from the reason string so consumers classify degrades WITHOUT string-matching the prose.
rubric_hash 16 hex chars blake2b(canonical_rubric_json, digest_size=8).hexdigest() (DEC-010). Carried on every record AND the sidecar.
prompt_version_template 16 hex chars blake2b-8 of the system prompt + cached rubric block + envelope tag. Constant across all criteria of one run.
criterion_prompt_hash 16 hex chars blake2b-8 of the per-criterion prompt fragment. Stable across artefacts of one run.
response_text_hash 16 hex chars blake2b-8 of the raw LLM response text. Empty string for degraded records (no response text to hash).
model string The Anthropic model id used for the call (e.g. claude-sonnet-4-6).
input_tokens integer Total input tokens billed for the call. 0 for degraded records.
output_tokens integer Total output tokens billed. 0 for degraded records.
cache_creation_input_tokens integer Tokens charged at 1.25× input pricing for cache writes. 0 on cache-read-only calls.
cache_read_input_tokens integer Tokens charged at 0.1× input pricing for cache reads.

Artefact-id format

artifact_id is a canonical dotted-path string (DEC-009). Six shapes the formatter emits — the same shapes the resolver in signalforge.grade.prompts.extract_artifact_text consumes:

  • column.<col>.description — column documentation.
  • column.<col>.rationale — column rationale.
  • model.description — model documentation.
  • model.rationale — model rationale.
  • test.column.<col>.<test.type> — column-scoped test (e.g. test.column.email.not_null).
  • test.model.<test.type> (or test.model.<test.type>.<args_hash>) — model-level test. The args_hash (8-hex blake2b-4 of the test's identifying args, sorted for argument-order invariance) appears only when two model-level tests share a test.type.

A drift in the formatter is caught downstream when the resolver produces a string that doesn't match ^(column|test|model)\..

Sidecar JSON schema

End-of-run, one JSON document per invocation at sidecar_path (default <project_dir>/.signalforge/grade.json). Single-document overwrite via O_WRONLY | O_CREAT | O_TRUNC (DEC-012); a re-run replaces the prior sidecar atomically (subject to platform truncate semantics). The sidecar size cap is 1 MiB (_GRADE_SIDECAR_RECORD_LIMIT_BYTES) — much larger than the JSONL audit's 4 KiB PIPE_BUF-bound limit because there is no concurrent-append contract.

The sidecar carries the same GradingReport shape the orchestrator returns:

{
  "grade_schema_version": 1,
  "signalforge_version": "0.1.0.dev0",
  "run_id": "a1b2c3d4e5f6478890aabbccddeeff00",
  "timestamp": "2026-05-01T17:42:13.123456Z",
  "duration_seconds": 12.473,
  "model_unique_id": "model.shop.dim_customers",
  "rubric_hash": "0123456789abcdef",
  "thresholds": [0.7, 0.5],
  "results": [
    {
      "artifact_id": "column.email.description",
      "criterion_id": "clarity",
      "score": 0.8,
      "passed": true,
      "evidence": "...",
      "reasoning": "..."
    }
  ],
  "pass_rate": 1.0,
  "mean_score": 0.8,
  "aggregate_complete": true,
  "passed": true
}

Aggregate computed fields (pass_rate, mean_score, aggregate_complete, passed) round-trip through the JSON via Pydantic's @computed_field serialisation — a sidecar reader gets the same view as a freshly-produced GradingReport.

run_id correlation (DEC-020). Every JSONL record from this run carries the same run_id as the sidecar's run_id. To pull every per-pair record for a sidecar:

RUN_ID=$(jq -r '.run_id' .signalforge/grade.json)
jq -c "select(.run_id == \"$RUN_ID\")" .signalforge/grade.jsonl

Partial-run signal. JSONL exists but the sidecar is absent → the run crashed mid-iteration (most likely an audit-write failure on a later pair, which propagated as GradeAuditWriteError). Per-pair JSONL records up to the crash point are durable receipts of the work that DID complete. The crash was deliberate (DEC-006 fail-closed) — an unaudited grade decision is, by definition, a verdict without a receipt, exactly the failure mode the audit exists to prevent.

Drift gates. tests/fixtures/grade/grade_event_v1.jsonl and grade_report_v1.json are the canonical schema fixtures; tests/grade/test_drift_detector.py pairs each production model (extra="ignore") with a one-off extra="forbid" strict mirror and validates against the fixture. Adding a field to GradeEvent / GradingReport / GradingResult without updating the strict mirror OR the fixture breaks the test loudly. Don't bypass.

Reproducibility hash fields

Three hash fields land on every GradeEvent, all 16-hex-char blake2b with digest_size=8. The cross-stage hash domain is consistent — a reviewer querying "what response text produced criterion X for artefact Y on date Z" can compare bytes verbatim across draft and grade JSONLs.

  • rubric_hashblake2b-8 of the canonical rubric JSON (list of {id, criterion} mappings, sorted by id, dumped with sort_keys=True, separators=(",", ":")). Deterministic and order-invariant by construction — swapping two criteria in the rubric tuple does not change the digest. Carried on every event AND on the sidecar GradingReport. Same rubric_hash across all records in a run = same rubric; differs = rubric changed mid-run (which doesn't happen in v0.1 since the rubric is locked at orchestrator entry, but a reader can still verify).
  • prompt_version_templateblake2b-8 of the system prompt + cached rubric block + envelope-tag template. Constant across all criteria of one run; constant across runs of the same SignalForge version with the same rubric.
  • criterion_prompt_hashblake2b-8 of the per-criterion prompt fragment. Stable across artefacts of one run; reading the same criterion's prompt fragment from a v0.2 deployment with a tweaked prompt template surfaces a hash drift even when the criterion text itself is unchanged.
  • response_text_hashblake2b-8 of the raw LLM response text. Empty string for degraded records (no response text). Mirrors LLMResponseEvent.response_text_hash (#5).

To find every JSONL record produced by a specific rubric:

jq -c 'select(.rubric_hash == "0123456789abcdef")' .signalforge/grade.jsonl

To verify a sidecar's rubric_hash matches the rubric still on disk, reload via _canonical_rubric_hash(DEFAULT_RUBRIC) from a Python session and compare; a mismatch means the rubric drifted between the graded run and the current code.

Cost guidance (DEC-014)

The grader's per-criterion fan-out (DEC-004) is the most expensive option of those evaluated in plans/super/7-quality-grader.md: one LLM call per (artefact × criterion) pair. Be clear-eyed about what this costs.

Reference numbers, with the assumptions. The default rubric has 4 criteria; a typical drafted dbt model has ~12 artefacts (column descriptions + column rationales + per-test rationales + model description + model rationale). A richer real-world fixture (the Austin bikeshare project used by the live e2e suite) exercises ~27 artefacts/model → ~108 grade calls/model — adjust the per-model figures below proportionally for your own model shape.

Per-provider per-model cost (Austin bikeshare fixture, 2026-05-29 measurement at pricing-table version 2026-05-28):

Provider × model Per-model cost Notes
Anthropic claude-sonnet-4-6 ~$0.38 Drafter + grader on the BQ [anthropic] variant; baseline for the cost-control discussion below.
OpenAI gpt-4o ~$0.21 Grader-only on the BQ [openai] variant; drafter still Anthropic (DEC-011 of #155 pins drafter fixture stability).
Gemini gemini-2.5-flash ~$0.045 Grader-only on the BQ [gemini] variant; cheapest grade run by ~10× thanks to flash-tier pricing.

These figures are a single 2026-05-29 measurement at pricing-table version 2026-05-28calibration signal, not a billing guarantee. Vendor pricing rotates; per-fixture artefact count varies; cache hit/miss state across a run drives ±5–10% noise on the Anthropic figure specifically. See plans/super/157-e2e-cost-and-parallel.md § "Measured baseline (2026-05-29)" for the full-suite rollup ($1.38/run across the three providers).

Per-provider default judge models (#187). When grade.model: is omitted the loader resolves to the calling provider's default judge (signalforge.llm.providers.PROVIDER_DEFAULT_MODELS). Anthropic defaults to Sonnet (the #187 calibration gate kept it the default — Haiku grades stricter, below the 85% bar); OpenAI/Gemini default to their fast judges (explicit operator choices of a cheaper provider). The rows below pair each default with its per-MTok USD list price from signalforge.llm.pricing (pricing-table version 2026-05-28) and an estimated per-model grade cost (estimate, not a measured run, except where noted):

Provider × default judge Input $/MTok Output $/MTok Est. per-model grade cost Notes
Anthropic claude-sonnet-4-6 $3.00 $15.00 ~$0.38 (measured) The default grade judge (calibration baseline). claude-haiku-4-5 ($0.80/$4.00, ~$0.10, ~3.75× cheaper) is the opt-in fast judge — stricter, see the calibration writeup.
OpenAI gpt-4o-mini $0.15 $0.60 ~$0.013 ~16.7× cheaper than gpt-4o per token; the default when provider: openai.
Gemini gemini-2.5-flash $0.30 $2.50 ~$0.045 Already the documented mid-tier default; the measured figure above is this same SKU.

For completeness, the registered Anthropic SKUs span claude-haiku-4-5 ($0.80 / $4.00 per MTok), claude-sonnet-4-6 ($3.00 / $15.00), and claude-opus-4-7 ($15.00 / $75.00) — opting into the Haiku judge (grade.model: claude-haiku-4-5) cuts the per-token grade cost ~3.75× vs the Sonnet default, at the cost of stricter grading (#187 calibration).

Fan-out comparison vs the batched alternative:

  • The per-criterion fan-out (one LLM call per (criterion × artefact)) is what the figures above measure.
  • vs. ~$0.05 per model batched (Q4=A in the plan — single judge call covering all criteria for one artefact at once). The per-criterion fan-out is ~3.4× more expensive.

Why fan-out anyway? Three load-bearing reasons (recorded in DEC-004 of the plan):

  1. Per-criterion retry isolation. One LLM call per criterion means one bad criterion (parser failure, retry-exhausted) does not fail-loud the whole report — the orchestrator routes that pair to the DEC-015 degraded path and the rest of the rubric still produces signal. Batched mode would force "all-or-nothing" parsing.
  2. Per-criterion prompt tuning headroom for v0.2. Each criterion already has its own prompt seam; adding a v0.2 per-criterion prompt override is a one-line config addition. Batched mode would require a prompt-template overhaul.
  3. Trivial anchor contract. Single criterion = no positional alignment problem in the response parser. Batched mode would have to validate a multi-criterion response array against the rubric ordering, exactly the kind of loose-contract surface the safety / draft layers' anchor contracts exist to avoid.

Cost-control knobs. Levers operators can pull when the default fan-out is too expensive for their use case:

  • budget_base_seconds / budget_per_pair_seconds (defaults 60 / 20.0) — The two terms of the scaled wall-clock backstop (issue #198): effective = budget_base_seconds + budget_per_pair_seconds × ceil(num_pairs / max_concurrent_calls). The backstop grows with model width and concurrency. It is a generous runaway catch, NOT a completion target (ample headroom over the #179 baseline — pinned non-binding at representative scales by test_default_scaled_budget_is_non_binding_at_representative_scale). Do not lower these defaults — the bias-to-completion posture (DEC-210) requires the default budget never passively bind; widen them only if a retest shows the limiter-paced wall-clock has grown.
  • total_budget_seconds (default None since #198) — Optional absolute hard cap on top of the scaled formula. None → use the scaled budget alone; set to an int → effective = min(scaled, total_budget_seconds). Setting it explicitly is the deliberate, documented way to opt into a wall-clock partial — those budget degrades are exempt from require_complete (see Bias-to-completion posture). A trip of the default scaled budget (total_budget_seconds is None) is not a legitimate partial: per DEC-204 it fails loud under require_complete (a Stage-1 sizing canary), never a silent aggregate_complete: false. (GradeBudgetExceededError stays reserved for a future hard "the run did nothing" failure where the budget trips before ANY criterion runs.)
  • max_grade_calls / max_grade_cost_usd / max_grade_tokens (all default None = off, issue #198) — Opt-in soft ceilings on judge calls / USD / token movement. Whichever trips first stops scheduling new pairs; the rest degrade (never raise) with a reasoning naming the ceiling, and the run emits one distinct grade ceiling exceeded WARNING. USD/token ceilings are best-effort (up to max_concurrent_calls − 1 in-flight calls may complete past the threshold because cost/tokens are known only post-call); max_grade_calls is near-hard via pre-call slot reservation. Cache-hit pairs (#189) make no LLM call and never count against any ceiling. See the field-by-field Configuration above for the accounting detail.
  • max_output_tokens (default 1024) — Per-call output cap. The expected JSON response is ~150 tokens, so the cap is a truncation guard, not a target; the default was raised from 256 to 1024 in #187 to substantially reduce truncation of a verbose one-line gemini-2.5-flash grade JSON (1024 reduces but does not fully eliminate it at the full-fixture scale — the per-provider floors below recommend 4096 for Gemini-heavy runs). Tightening it trims the output-token bill at the cost of truncation risk (handled by GradeOutputError(violation_type="json_parse") and the degraded path); see the per-provider floors below before lowering it.
  • cache_ttl: "1h" (default) — Cache-read economics. Prompt caching is a provider capability (issue #135): the cache_control marker, the extended-cache-ttl beta header, and the pre-send count_tokens gate are emitted only when the selected LLMProvider reports supports_prompt_caching / supports_token_count. A provider that supports neither reports 0 cache tokens and skips the marker; the default anthropic provider supports both, so the economics below are unchanged. The cached block (system prompt + rubric block) is constant across every call in one grade_artifacts invocation; a 60-call run reads the cache ~59 times after one write. Cache reads are 0.1× input pricing vs. 1.25× for writes; the break-even is ~2 reads per write. Switching to cache_ttl: "5m" is rarely worth it — the only failure mode the shorter TTL catches is a multi-hour run where the cache would otherwise expire mid-iteration, which means the per-call reads stop landing, which the dual-zero cache-anomaly WARNING (DEC-014 of #5) surfaces loudly. Leave at "1h" unless you have a specific reason.

v0.2 will offer batched-criteria as opt-in for cost-conscious operators (DEC-014). The current architecture preserves the option: each criterion has its own prompt seam already, so a cost_mode: batched flag is additive rather than a rewrite.

No per-provider override is enforced in code — GradeConfig.max_output_tokens is one knob across every provider. The floors below are observed-data recommendations from live grading runs; operators can lower for cost-cutting but must validate quality afterward. Truncated judge responses surface as LLMResponseFormatError (the provider-neutral is_clean_completion gate raises on any non-clean finish_reason — Anthropic stop_reason="max_tokens", OpenAI finish_reason="length", Gemini finish_reason="MAX_TOKENS") and degrade the pair with reasoning="call failed: GradeLLMError: <inner finish_reason message>" per #155 DEC-005 + #158 (the inner provider message — naming the actual finish_reason value — is surfaced into the audit JSONL so a residual degrade is self-diagnosing without re-reading stderr).

Provider Recommended floor Rationale
Anthropic (Sonnet 4.6+) 1024 Sufficient for full reasoning; tested in BQ smoke.
OpenAI (gpt-4o) 1024 Same headroom; no observed truncation.
Gemini (2.5-flash+) 4096 Verbose reasoning style; 512 / 1024 observed truncating mid-string (#155 DEC-008). 2048 verified safe at the 5-pair in-isolation smoke scale but #158 found 5–6/108 pairs still degrade at 2048 on the full Austin fixture — 4096 is the fixture-scale floor.

Fixture-scale caveat (issue #158): these floors are necessary but not sufficient — Gemini's per-pair reasoning length is high-variance, so a fixture with substantially more artifacts than the Austin bikeshare e2e (~27 artifacts × 4 criteria = ~108 pairs) may still see residual MAX_TOKENS degrades at 4096. The honest guidance is to treat the floor as fixture-scale-dependent: validate with a full-fixture run, watch GradingReport.aggregate_complete, and bump if any pair degrades with reasoning mentioning finish_reason='MAX_TOKENS'. The diagnostic in the degrade reasoning tells you exactly which finish_reason fired.

OpenAI provider

Issue #136 registered OpenAIProvider as the second signalforge.llm.providers.LLMProvider. Select it by setting grade.provider: openai in signalforge.yml:

grade:
  provider: openai
  model: gpt-4o            # explicit override; omit `model:` to auto-resolve to the OpenAI fast default `gpt-4o-mini` (#187). Any model id the SDK accepts is allowed.
  # cache_ttl, max_retries_*, total_budget_seconds, thresholds — same shape as the anthropic provider

Requirements:

  • Install extra: pip install signalforge-dbt[openai] (or uv sync --dev in a contributor checkout). Pulls openai>=1.40 plus tiktoken for the --estimate cost-preview path.
  • Env var: OPENAI_API_KEY (mirrors ANTHROPIC_API_KEY for the default provider).
  • Pricing SKUs registered: gpt-4o, gpt-4o-mini, gpt-4.1, gpt-4-turbo. Other model ids raise EstimateUnknownModelError from the --estimate path (the live judge call still runs; --estimate is the only surface that requires a pricing row). See docs/cost-estimate-ops.md.

No prompt caching (cost note). OpenAI's Chat Completions surface does not expose Anthropic-style prompt caching. OpenAIProvider reports supports_prompt_caching=False and supports_token_count=False, which means the orchestrator (signalforge.llm.call_llm) skips both the cache_control marker and the pre-send count_tokens gate (issue #135 DEC-008). Every grading call ships the full system + rubric block — there is no cached read discount on subsequent criteria, so the per-(artefact × criterion) fan-out (see Cost guidance above) costs a flat input-token bill on every call. Budget accordingly: a 4-criterion × 12-artefact run is 48 full system+rubric sends, not one write + 47 reads. v0.3 ships without prompt caching; OpenAI's recent prompt-cache mechanism is a candidate for a follow-up.

Server-enforced JSON. OpenAIProvider.build_create_kwargs attaches response_format={"type": "json_object"} so the judge model is forced to emit valid JSON server-side (DEC-006). The tolerant extract_json_payload parser (issue #144) remains as defence-in-depth for the same prose-preamble drift class the Anthropic path handles.

Live smoke gating. A gated @pytest.mark.openai real-API end-to-end test exercises grading against gpt-4o. Run it with:

SF_RUN_OPENAI=1 OPENAI_API_KEY=sk-... uv run pytest -m openai --no-cov

Mirrors the @pytest.mark.anthropic precedent — excluded from the default CI run via addopts -m 'not openai'; both env vars are required (each missing var produces a clear skip reason). See docs/cost-estimate-ops.md for the maintainer's three-test smoke set (grader + drafter + --estimate).

Gemini provider

Issue #137 registered GeminiProvider as the third LLM provider behind the provider-neutral seam (#135). Select it via grade.provider: gemini in signalforge.yml:

grade:
  provider: gemini
  model: gemini-2.5-flash    # default mid-tier judge; gemini-2.5-pro and gemini-2.0-flash are also registered
  cache_ttl: 1h              # accepted but ignored — Gemini ships without caching in v0.3
  • Install extra: pip install signalforge-dbt[gemini] (or uv sync --dev in a contributor checkout). Pulls google-genai>=0.5,<1.
  • Env var: GOOGLE_API_KEY (read by the SDK; SignalForge never logs it).
  • Server-side JSON enforcement: GeminiProvider.build_create_kwargs sets response_mime_type="application/json" on the GenerateContentConfig (DEC-018 of #137). Belt-and-braces with the tolerant extract_json_payload parser.
  • Safety-filter / non-clean finish_reason handling: Two related paths both route through the same degrade. (1) The provider-neutral LLMProvider.is_clean_completion(response) gate inside call_llm (DEC-005 of #155) raises LLMResponseFormatError when finish_reason is anything but STOP — including SAFETY, RECITATION, OTHER, MAX_TOKENS (even when partial text is present). (2) The legacy GeminiProvider.extract_text_blocks raise (DEC-005 of #137) still fires when zero text parts are returned. Either way, the grade engine wraps the result as GradeLLMError and degrades the affected pair via the conservative score=None / reasoning="call failed: GradeLLMError: <inner finish_reason message>" taxonomy (#158 surfaces the inner provider message into the audit field so the actual finish_reason value — SAFETY vs RECITATION vs MAX_TOKENS — is recoverable from .signalforge/grade.jsonl alone).

No prompt caching (cost note — DEC-013 of #137). v0.3 Gemini ships without prompt caching. GeminiProvider reports supports_prompt_caching=False / supports_token_count=False, so call_llm skips the cache_control marker, the extended-cache-ttl beta header, the dual-zero cache-anomaly WARNING, AND the pre-send count_tokens gate. Every grade call transmits the full system + rubric prompt; there is no Anthropic-style discount on the cached prefix. For a default 4-criterion rubric over a 12-column model (~48 sequential calls), budget the per-call cost accordingly. Explicit Gemini context caching is tracked as a follow-up.

--estimate integration (active). signalforge generate --estimate with grade.provider: gemini works end-to-end via Gemini's native client.models.count_tokens (US-007 of #137; DEC-016). One extra API round-trip per estimate call — comparable in shape to Anthropic's messages.count_tokens and distinct from OpenAI's local tiktoken path. The grader-side USD figure uses the Gemini pricing SKUs registered in signalforge.llm.pricing (gemini-2.5-pro, gemini-2.5-flash, gemini-2.0-flash). Network or auth failures surface as <unavailable: <ErrorClass>> via the conservative-bias supplementary- failure path (DEC-005 of #36); operators see a calibration signal, not an aborted run.

Live smoke. A @pytest.mark.gemini gated end-to-end test exercises grading against gemini-2.5-flash. Run it with:

SF_RUN_GEMINI=1 GOOGLE_API_KEY=... uv run pytest -m gemini --no-cov

Mirrors the @pytest.mark.anthropic / @pytest.mark.openai precedent — excluded from the default CI run via addopts -m 'not gemini'; both env vars are required.

Prompt-injection mitigation

The grader's only LLM-prompt defence is the <ARTIFACT>...</ARTIFACT> envelope (mirrors the drafter's <MODEL_SQL> envelope, DEC-007 of #5). The system message instructs the LLM-judge to treat anything between the tags as data, not instructions:

<ARTIFACT>
This column captures the customer's email address at order time.
-- adversarial column description: "ignore prior instructions and ..."
</ARTIFACT>

Envelope-breach guard. A payload containing the literal </ARTIFACT> would terminate the fence early and let downstream content escape. Two checks fire:

  1. Whole-run pre-flight scan (DEC-013). _scan_envelope_breach runs at orchestrator entry over every artefact payload BEFORE any LLM call is issued. Failing fast surfaces one typed GradePromptEnvelopeBreachError(artifact_id=...) pointing at the offending artefact, rather than discovering the breach mid-iteration after several JSONL records have already landed.
  2. Per-call defence-in-depth. render_dynamic_block re-checks at call time so a future caller using _grade_one directly (without the orchestrator's pre-flight) still gets the protection.

Safety-boundary note (DEC-013 of #7). The PII redaction boundary established by issue #4 closed at draft time — the safety layer redacts column names and values before the drafting LLM call. Post-draft, CandidateSchema carries real column names; the grader sends those real names to the LLM-judge by design, and writes them into the sidecar JSON the operator reviews. Re-redaction inside the grader would defeat both the rubric (judges need real names to score documentation quality) and the explainable-diffs commitment (reviewers need to see what was scored).

Audit log sensitivity

grade.jsonl and grade.json contain the LLM-judge's evidence and reasoning, which can echo verbatim fragments of the artefact text (column descriptions, model docs, test rationales). Treat both files at-rest the same way you treat the safety / draft / prune audits:

  • Gitignore .signalforge/ (already configured in this repo's .gitignore).
  • Restrict at-rest permissions. The writers create files at 0o600 on first call; the parent directory is created via mkdir(parents=True, exist_ok=True) (Python's mkdir does not tighten an existing directory's permissions, so verify the existing .signalforge/ mode is 0o700 on shared hosts).
  • Don't ship as a build artefact. Strip from container images and CI uploads.
  • Symlink-hardened paths. Both writers route audit_path and sidecar_path through signalforge.warehouse._path_safety.canonicalise_path at writer entry. A symlinked .signalforge/grade.jsonl -> /etc/passwd is rejected as GradeAuditWriteError (wrapping the underlying ProfileNotFoundError) before the os.open ever fires.

Debugging

Logger name: signalforge.grade.engine (and sibling modules under signalforge.grade).

import logging
logging.getLogger("signalforge.grade").setLevel(logging.DEBUG)

Levels:

  • INFO — One line per grade_artifacts invocation at the end of the run, lazy-format JSON per DEC-027 (run_id, model_unique_id, pass_rate, mean_score, passed, aggregate_complete, duration_seconds, results). Mirrors safety-layer.md DEC-022 / llm-drafter.md DEC-011 / prune-engine.md DEC-017 — never f-string-interpolate user-controlled strings into a logger call.
  • WARNING (wall-clock budget) — One line when the effective wall-clock budget trips, JSON-encoded {run_id, model_unique_id, completed_count, degraded_count, effective_budget_seconds}. The field is effective_budget_seconds (renamed from total_budget_seconds in #198 DEC-008) — it carries the computed effective budget actually passed to asyncio.timeout, i.e. the scaled formula optionally capped by total_budget_seconds.
  • WARNING (ceiling) — One line when any opt-in max_grade_calls / max_grade_cost_usd / max_grade_tokens ceiling trips (issue #198 DEC-007), distinct from the wall-clock budget WARNING. JSON-encoded {run_id, model_unique_id, ceiling, limit, completed_count, degraded_count} where ceiling ∈ {"calls", "cost_usd", "tokens"} (the first ceiling to trip) and limit is its configured value.
  • Plus the inherited signalforge.llm retry warnings (one per retry attempt at the LLM seam).
  • DEBUG — Reserved for future per-criterion latency observability; v0.1 emits no DEBUG from the engine.

The grade layer never logs full evidence / reasoning content. The audit JSONL is the single durable record of decision-level detail; logger output is a hint that the decision happened, not what was in it. The custom __repr__ on GradingResult and GradingReport defends accidental _LOGGER.warning("result: %s", result) calls from dumping multi-paragraph reasoning into log sinks.

Reading a fail-closed GradeAuditWriteError. The cause is exposed as .cause and on __cause__. Common causes:

  • Parent directory not writable (no +w for the user, or .signalforge/ is a symlink to a read-only mount).
  • Disk full (ENOSPC).
  • Symlink containment violation (the audit / sidecar path canonicalises outside <project_dir>). The cause is a signalforge.warehouse.errors.ProfileNotFoundError.
  • Oversize record (raises GradeAuditRecordTooLargeError instead — for the JSONL writer, reduce the LLM's reasoning payload via max_output_tokens; for the sidecar, the 1 MiB cap is generous enough that hitting it suggests a runaway LLM response that should already have been rejected by the JSONL writer's per-call cap).

Failure modes / typed-error cross-reference

Class When raised Where it surfaces How to fix
GradeError Base class; never raised directly. signalforge.grade.errors Catch it to handle every grade-layer failure uniformly.
GradeConfigError signalforge.yml grade: block failed parse / schema validation (extra="forbid", out-of-range knob, malformed rubric override). load_grade_config Inspect the grade: block. Typos like mdoel: are caught here.
GradeRubricError The resolved rubric is empty or carries duplicate id values. validate_rubric (called at grade_artifacts entry and inside load_grade_config's rubric validator) Provide at least one criterion; ensure every id is unique.
GradeLLMError One-level wrap of signalforge.llm.LLMError. Retry budget exhausted, auth failure, server error, malformed cache block. _grade_one per pair (degraded by orchestrator); only escapes if the entire run can't recover. Inspect .cause / __cause__ for the underlying LLM-layer detail. Common: missing ANTHROPIC_API_KEY, rate-limit exhaustion.
GradeBudgetExceededError Reserved; NOT raised in v0.1. Budget exhaustion always degrades per-pair (aggregate_complete=False); this class is held for a future hard "the run did nothing" failure (budget trips before the first pair). Not currently raised. If a partial run (aggregate_complete=False) is undesirable, raise budget_base_seconds / budget_per_pair_seconds (or lift the total_budget_seconds cap if set), narrow the candidate set, or reduce the rubric's criterion count.
GradePromptEnvelopeBreachError An artefact payload contains the literal </ARTIFACT> close tag. Whole-run pre-flight _scan_envelope_breach; per-call defence-in-depth in render_dynamic_block. Inspect the offending artefact (exc.artifact_id); remove the literal tag from the column description / rationale.
GradeOutputError LLM-judge response failed parse / anchor-contract validation. Carries violation_type. parse_grade_response per pair (degraded by orchestrator). Pattern-match on .violation_type (json_parse, criterion_id_mismatch, score_out_of_range, …). Re-running typically resolves transient JSON failures; structural mismatches usually point at a prompt-template regression.
GradeAuditWriteError Fail-closed audit / sidecar write failure (OSError, PermissionError, encoding, fsync, symlink containment). DEC-006 / DEC-012. write_grade_event / write_grading_report (wrapped at the orchestrator's audit-write seams). Verify <project_dir>/.signalforge/ is writable, has disk space, and is not a symlink escaping the project tree. Fix the I/O issue and re-run.
GradeAuditRecordTooLargeError Serialised JSONL line > 4000 bytes OR sidecar JSON > 1 MiB. Raised BEFORE any file open. write_grade_event / write_grading_report. For JSONL: reduce max_output_tokens so the judge's reasoning stays short; for sidecar: investigate any single result with megabyte-range reasoning (likely a prompt regression).

Regen instructions for fixtures

The grade-layer fixtures at tests/fixtures/grade/grade_event_v1.jsonl and grade_report_v1.json are hand-authored, not produced by a live LLM run. They exist solely as drift gates for the extra="forbid" strict mirrors in tests/grade/test_drift_detector.py. To regenerate after a model field change:

  1. Update the production model (signalforge.grade.modelsGradeEvent, GradingReport, or GradingResult).
  2. Update the matching strict mirror in tests/grade/test_drift_detector.py. Both must change in the same commit, or the strict-validates-fixture check fails.
  3. Edit the fixture JSON / JSONL by hand to add / remove the field. Keep the values readable (no test fixture should be a wall of placeholder hashes).
  4. Run pytest tests/grade/test_drift_detector.py -v — the strict model must validate the fixture after the change.

tests/fixtures/grade/sample_candidate.json is also hand-authored; regen by editing in place. v0.2 may add a regenerate.sh script under tests/fixtures/regenerate.sh-style convention if the rubric / model shape stabilises enough that a deterministic generator pays for itself.

tests/fixtures/grade/example_config.yml is the source of truth for the worked example in Configuration above — the test_load_grade_config_doc_example_round_trips test loads it through load_grade_config and asserts the defaults from DEC-023..DEC-027 are populated, so the doc and the loader cannot silently drift.

CLI integration note

Tracked in issue #9. The signalforge generate CLI will load the grade config via load_grade_config(...) and invoke grade_artifacts(...) after the prune step completes; the diff renderer (#8) consumes the returned GradingReport to render per-criterion stars + per-artefact one_line_why lines (Architectural Commitment #5 — explainable diffs). When fail_on_below_threshold: true is set in signalforge.yml, the CLI catches the resulting GradeBelowThresholdError and exits with the INPUT exit-code tier (exit 2) — see Threshold-fail behaviour above and docs/cli-ops.md once US-009 lands. The default posture (report-only) is preserved: a below-threshold run with the default fail_on_below_threshold: false returns the report and the CLI exits 0 once the diff renders.

References

  • Design record: plans/super/7-quality-grader.md.
  • Prune-layer counterpart (the layer the grader mirrors most patterns from for fail-closed audit + budget semantics): docs/prune-ops.md.
  • Drafter counterpart (the layer the grader mirrors for the LLM SDK seam, prompt-injection envelope, and per-call response audit): docs/draft-ops.md.
  • Safety-layer counterpart (the layer that establishes the fail-closed JSONL convention and extra="forbid" config-shape rule): docs/safety-ops.md.
  • Manifest reader conventions (frozen / extra="ignore" / drift-detector pattern): .claude/rules/manifest-readers.md.

Cross-reference DECs: DEC-001 (public API surface), DEC-002 (per-pair score shape + threshold-AND aggregate), DEC-004 (per-criterion fan-out — one LLM call per (artefact, criterion) pair), DEC-005 (fail_on_below_threshold graduated in #9 / US-002 / DEC-021), DEC-006 (fail-closed JSONL audit), DEC-007 (locked default rubric), DEC-009 (canonical artifact_id dotted-path format), DEC-010 (rubric_hash reproducibility), DEC-011 (Rubric as TypeAlias), DEC-012 (end-of-run sidecar JSON), DEC-013 (whole-run envelope-breach scan), DEC-014 (cost transparency — this doc's Cost guidance section), DEC-015 (degraded path on retry-exhaustion / parser-failure / budget-exceeded), DEC-016 (default rubric criterion text + threshold defaults), DEC-017 (Criterion shape), DEC-018 (criterion-outer / artefact-inner iteration order; one_line_why cap), DEC-020 (run_id), DEC-022 (project_dir semantics), DEC-023..DEC-027 (locked config defaults), DEC-028 (nine-class error hierarchy).