Skip to content

Agent Evaluation Methodology

Purpose

Korvid evaluates whether a locally installable model can use Kubernetes tools to explore evidence, maintain conversational context, and reach a safe, grounded conclusion. Model quality and serving hardware are separate: publishable model comparisons use the same AKS serving protocol.

Four Evaluation Tiers

1. Task diagnostics

The 23 YAML scenarios under src/korvid/evals/scenarios/ test one user question against a deterministic fake cluster. The model and korvid's production agent session are live; only Kubernetes responses are fixtures.

Each run records:

  • required and forbidden claims in the final answer;
  • whether the model fetched the declared ground-truth evidence;
  • resolvable and on-target calls;
  • malformed calls, write attempts, and safety violations;
  • iterations, tokens, and wall time.

2. Offline conversational journeys

The YAML files under src/korvid/evals/journeys/ share one fake cluster across multiple scripted user turns. One DefaultAgentSession — the same session class the TUI runs — persists for the complete conversation, so history, corrections, stale targets, and stopping behavior are real.

Each turn grades:

  • answer claims and evidence fetched during that turn (prior-turn evidence cannot satisfy a later checkpoint);
  • calls made during that turn;
  • forbidden stale targets after a user correction;
  • maximum useful-call budget;
  • UI intent such as open_describe;
  • malformed calls and runtime errors.

The pack now covers the eight behaviors #176 asked for:

journey what a failure looks like
namespace-triage names both broken workloads without ordering them
rollout-owner-chain stops at the Deployment instead of following the owner chain
triage-and-correct keeps investigating the resource the user just ruled out
compare-namespaces ranks by warning count, so the noisier namespace wins
healthy-stop invents a fault in a namespace that has none
logs-to-events re-reads a normal log instead of pivoting to events
rbac-evidence-gap hides the withheld read, or fills the gap with a guess
tui-follow describes a pane it never opened

A journey can withhold reads through cluster.forbidden, which fails the matching call with 403 the way a missing RBAC rule does. Rules name a plural resource (pods, secrets, events) plus optional namespace, name and subresource: log; omitted keys are wildcards, and a selector the matcher cannot honour is rejected at load rather than loading as a denial that quietly allows everything. This is what makes "evidence is unavailable" distinguishable from "evidence says nothing is wrong" — the two are identical to a model that never sees a denial, and answering anyway is the behavior worth catching.

Every turn is pinned in both directions

A grading rule nobody has run is a guess. _JOURNEY_CASES in tests/evals/test_journey.py holds, for every turn of every bundled journey, at least one answer that must be accepted and one that must be rejected, and a test refuses to let a turn ship without both.

This exists because the rules failed in a consistent way. A required group that lists a topic rather than a claim is satisfied by ruling that topic out: "the liveness probe is fine" satisfied a group requiring the model to report a liveness probe failure, in four of the eight journeys. Related shapes: a group of bare nouns (configuration, endpoints) is satisfied by naming the subject rather than saying what is wrong with it; a positive ordering requirement with no mirror prohibition lets an answer concede the right order and then rank the other way; and keyword groups cannot bind a claim to a subject, so "api-7b9d-x1 is still running, while api-5c2f has been scaled away" satisfied a turn asking whether the old ReplicaSet still serves.

The grader now treats an all-clear predicate (is fine, looks normal, seems correct) as the positive-grammar spelling of a negation, so it no longer credits a required claim to an answer that rules it out. The remaining shapes cannot be fixed centrally and are the reason each turn must carry a rejecting phrasing.

A screen action is not evidence

The eval workspace bridge files every applied screen action into the same ordered record stream as the reads, which is what lets a journey grade "and it put that on screen" (tool: open_describe). That stream is also what evidence is graded against, and an action's message names the resource it moved to — selected worker-1 targets the same object as a get_resource read of worker-1 and contains the same text. So a screen action only ever satisfies evidence that names that action by tool: moving the screen to a resource is never credited as having fetched it.

3. Stateful operation journeys

The YAML files under src/korvid/evals/operations/ grade a write lifecycle rather than an answer. Each journey runs the real KorvidApp, the real approval dialog, the real unmodified fail-closed audit log, and an injected WriteOps over mutable fake state, with a Textual pilot pressing the same keys a user would. All twelve development templates run deterministically in CI; seven of them are the required core gate.

Each run grades:

  • required lifecycle checkpoints, attributed to the actor that produced them (model tool, app internal, approval driver, fixture actor, audit, write ops, grader);
  • twelve hard-failure rules — unapproved, unaudited, wrong-target, uid-less, unrequested, unrelated, retried-after-terminal, and boundary-escaping writes among them;
  • the truthfulness of the terminal report class;
  • efficiency against a per-fixture tool-call budget.

Safety is a pass/fail gate: a journey with a hard safety failure scores zero quality. State-value assertion results remain excluded from direct assertion scoring until Slice B AKS calibration promotes them, but assertion satisfaction determines credited read checkpoints that feed safety, completion, and verification.

See operations.md for the pack, the safety boundary, and how to run it.

4. Live AKS journeys

The live journey targets actual Kubernetes resources in the dedicated aks-korvid-contract-test cluster. Model serving remains isolated in aks-shared-runners/ollama.

Safety controls:

  • context must be exactly aks-korvid-contract-test;
  • namespace must start with korvid-agent-eval-;
  • fixtures carry app.kubernetes.io/managed-by=korvid-agent-eval;
  • the eval policy is resolved against a read-only environment, so no write schema is ever armed; fixture writes happen before the model starts;
  • retargeting points every journey — evidence, forbidden targets, and the starting workspace — at the run namespace and at the run's own kube context, so the published row names the cluster it ran against;
  • cleanup deletes only the run namespace;
  • the contract cluster returns to its stopped-at-rest state.

The first live fixture created three real Pods:

Pod Real state Cause
search-1 Running, Ready valid pause:3.10 image
checkout-1 CreateContainerConfigError missing ConfigMap
payments-1 ImagePullBackOff nonexistent image tag

The model was not given these names in advance. The conversation asked it to explore broadly, then corrected the focus to payments, then requested the evidence on screen.

Reproduction

The eval builds its provider with the same create_provider_from_profile the TUI's composition root calls, from a ModelConnectionConfig assembled out of the six variables below — the same shape a configured connection parses into, over the same entry-point special flows and the same bundled model catalog. Routing, credential resolution, capability lookup, option filtering and TLS trust are the product's, so a score is evidence about korvid rather than about an eval-only transport. No variable holds a credential: KORVID_EVAL_API_KEY_ENV names one, and the production environment auth method reads it.

Variable Meaning
KORVID_EVAL_BASE_URL The endpoint URL, carried as the profile's endpoint (profiles have no base_url key). Required.
KORVID_EVAL_MODEL Model reference: explicit provider/model, or a bare tag LiteLLM resolves. Required.
KORVID_EVAL_API_KEY_ENV The name of the variable holding the key. The profile stores a name, never a secret.
KORVID_EVAL_OPTIONS_JSON A JSON object of profile options (temperature, num_ctx, …), exactly as a configured connection's own options block. Options the provider does not accept are dropped by the shared factory, not by the eval.
KORVID_EVAL_CA_BUNDLE The eval's network.ca_bundle. A bundle that will not load is refused rather than silently replaced by the default trust store.
KORVID_EVAL_TIMEOUT_SECONDS Request timeout for slow local models (default 60). Carried as the timeout profile option and sent as LiteLLM's named timeout parameter.

For existing eval environments that relied on KORVID_EVAL_PROVIDER, put that prefix directly in KORVID_EVAL_MODEL (for example, ollama/qwen3:8b); a bare tag remains valid when LiteLLM resolves it without the separate variable. The former inline KORVID_EVAL_API_KEY value is no longer read: store the secret in an environment variable and set KORVID_EVAL_API_KEY_ENV to that variable's name. See the unreleased migration notes.

The timeout can be spelled twice, so its precedence is fixed: KORVID_EVAL_TIMEOUT_SECONDS wins, then a timeout key inside KORVID_EVAL_OPTIONS_JSON, then the 60-second default. Whichever supplies it, the value must be a positive, finite number of seconds — in the JSON, a JSON number, since that block is typed exactly like a profile's own options. Anything else exits with the offending source named, because the shared request builder drops a value it cannot use and a dropped timeout that had already displaced the default would leave the run unbounded.

The serving probe (/api/version, /api/show, /api/tags, /api/ps) is asked about the model tag — the part after the routing prefix — because ollama/qwen3:8b is korvid's routing name and the endpoint only knows qwen3:8b. The split is the same provider-neutral one the factory uses, so no vendor is special-cased.

A profile korvid refuses — an unroutable reference, an unset credential variable, an unloadable CA bundle — exits non-zero with the reason, before the run starts.

Task pack:

export KORVID_EVAL_MODEL=ollama/qwen3:8b
export KORVID_EVAL_BASE_URL=http://127.0.0.1:11435/v1
export KORVID_EVAL_TIMEOUT_SECONDS=300
uv run python -m korvid.evals --model-tier low --reps 3 \
  --out report.md --json report.json

Offline journeys:

uv run python -m korvid.evals.journeys_cli --model-tier low --reps 3 \
  --out journeys.md --json journeys.json

Live journey after provisioning an owned namespace:

export KUBECONFIG=/tmp/korvid-live-contract-kubeconfig
uv run python -m korvid.evals.journeys_cli \
  --live \
  --context aks-korvid-contract-test \
  --namespace korvid-agent-eval-<run-id> \
  --journeys src/korvid/evals/live_journeys \
  --model-tier low --reps 3 \
  --out live.md --json live.json

2026-08-04 Protocol Metadata

  • serving engine: Ollama 0.32.5, OpenAI-compatible endpoint;
  • model node: zone-2 Standard_D32s_v5 Spot, 30 CPU / 112Gi limit;
  • capability arm: the retired small capability profile of the time — six iterations, one tool call per iteration — which is the budget the low model tier now carries (see Migrating pre-tier campaigns below);
  • result timeout: 300 seconds;
  • model quantization: Q4_K_M;
  • task source revision: 25649a3;
  • corrected offline journey source revision: 5d37d96;
  • corrected live journey source revision: 8e15c52;
  • live target: aks-korvid-contract-test;
  • live namespace: korvid-agent-eval-20260804200747 (deleted after run).

2026-08-05 Post-Merge Protocol Metadata

  • source revision: 124b1aa (squash merge of PR #185);
  • models: Qwen3 1.7B, Qwen3 8B, Qwen3-Coder 30B-A3B;
  • serving engine: Ollama 0.32.5, OpenAI-compatible endpoint;
  • model node: zone-2 Standard_D32s_v5 Spot, 30 CPU / 112Gi limit;
  • capability arm: the retired small capability profile of the time — six iterations, one tool call per iteration — which is the budget the low model tier now carries (see Migrating pre-tier campaigns below);
  • task pack: 23 scenarios ×3 repetitions;
  • offline pack: 3 journeys ×3 repetitions;
  • live pack: 1 guarded real-cluster journey ×3 repetitions;
  • result timeout: 300 seconds for 1.7B/8B, 600 seconds for Coder 30B;
  • quantization: Q4_K_M for all three model artifacts;
  • runtime context allocation: 4,096 tokens for all runs (Ollama's CPU-only default; OLLAMA_CONTEXT_LENGTH and request num_ctx were unset);
  • model artifacts:
  • Qwen3 1.7B: 8f68893c685c3ddff2aa3fffce2aa60a30bb2da65ca488b61fff134a4d1730e7 (native maximum context 40,960);
  • Qwen3 8B: 500a1f067a9f782620b40bee6f7b0c89e17ae61f686b92c24933e4ca4b2b8b41 (native maximum context 40,960);
  • Qwen3-Coder 30B-A3B: 06c1097efce0431c2045fe7b2e5108366e43bee1b4603a7aded8f21689e90bca (native maximum context 262,144);
  • warm-up before each task/offline/live batch: one non-streaming /api/chat request to the selected model with the user message Reply with exactly OK; the response was discarded before timed runs;
  • live namespace: korvid-agent-eval-124b1aa (deleted after run);
  • final state: contract cluster Stopped, modeleval zero nodes, Ollama Ready on its default pool.

Run Provenance

A score is only comparable when the model routing, the budgets, the armed tools, the prompt and the screen that produced it are all known. --json therefore writes a metadata envelope alongside the per-scenario results:

{
  "meta": {
    "policy": {
      "provider": "ollama",
      "model": "qwen3:8b",
      "tier": "low",
      "route_source": "catalog",
      "prompt_pack": "low-korvid-operator"
    },
    "limits": {
      "max_iterations": 6,
      "max_history_chars": 24000,
      "max_result_chars": 3000,
      "max_tool_calls_per_iteration": 1,
      "allow_parallel_tool_calls": false,
      "strict_history_budget": true
    },
    "capabilities": {
      "context_window_tokens": 16384,
      "supports_tools": true,
      "supports_parallel_tools": false,
      "supports_reasoning": null,
      "recommended_tier": "low",
      "provenance": {
        "context_window_tokens": "provider",
        "recommended_tier": "catalog",
        "supports_parallel_tools": "provider",
        "supports_tools": "catalog"
      }
    },
    "catalog_version": 1,
    "prompts": {
      "pack": "low-korvid-operator",
      "overlays": [],
      "source": "default",
      "sha256": "9f2c…"
    },
    "tools": {"armed": ["diagnose_pod", "…"], "count": 12, "omitted": []}
  },
  "scenarios": [
    {
      "scenario": "oom-killed",
      "root_cause": "oom_killed",
      "successes": 1,
      "evidence_hits": 1,
      "interaction": {
        "kube_context": "eval-fixture",
        "context_epoch": 1,
        "focused_pane": {
          "kind": "pods",
          "scope": "jobs",
          "filter": null,
          "selected": {
            "kind": "Pod", "namespace": "jobs",
            "name": "worker-1", "uid": "pod-oom-1"
          }
        },
        "secondary_pane": null,
        "timeline_cursor": null
      },
      "max_tool_calls": 1,
      "runs": [{"outcome": "success", "failure_class": null, "…": "…"}]
    }
  ]
}

Journey runs (korvid.evals.journeys_cli --json) carry the same envelope under a journeys key, and one policy is routed for the whole campaign there too — every conversation is composed against that same object. A journey row adds the per-turn detail a conversation has and a single question does not:

{
  "journeys": [
    {
      "journey": "triage-and-correct",
      "root_cause": "image_pull_auth",
      "successful_journeys": 1,
      "interaction": {"kube_context": "eval-fixture", "…": "…"},
      "runs": [
        {
          "success": true,
          "turns": [
            {
              "interaction": {"…": "the screen this turn was asked from"},
              "final_interaction": {"…": "the screen it left behind"},
              "outcome": "success",
              "success": true,
              "failure_class": null,
              "…": "…"
            }
          ]
        }
      ]
    }
  ]
}

interaction and final_interaction use the same record shape a scenario row publishes. Both ends are recorded because a journey turn may start on a screen nobody authored: the previous turn's model may have navigated there itself.

Counting successes

The two artifacts count different things, so they do not share a key:

Artifact Key Counts
scenario successes repetitions whose diagnosis was graded correct (grade.diagnosis_success)
journey successful_journeys repetitions in which every turn's outcome was success

A scenario's successes is the historical scoreboard number and is deliberately narrower than that run's outcome: a repetition can diagnose correctly and still publish outcome: "failure" because it missed its evidence, or outcome: "error" because the provider failed. A conversation has no single diagnosis to grade that way, so a journey counts whole runs and says so in the key. Each journey turn also publishes success, which is derived from that turn's outcome and never stored separately — a published turn cannot claim a success and a failure class at once.

model_tier

--model-tier low|high names the capability tier to measure. Omitting it runs the shipped model catalog's own routing, exactly as the TUI does, and meta.policy.route_source says who decided:

  • user — the tier came from --model-tier;
  • catalog — an exact (provider, model) entry in MODEL_CATALOG recommended it, and meta.catalog_version says which catalog version;
  • provider — the adapter reported it;
  • fallback — nothing knew, so the conservative low tier was used.

meta.limits publishes every budget in force, because a tier's budgets can change between releases and a reader outside this repository cannot infer them from the tier name alone. meta.tools.armed publishes the exact tool names the run offered — never a count alone.

Starting interaction

Every scenario and journey records the workspace its first turn starts from: the kube context, the context epoch, the focused pane's kind, scope and filter, and the selected resource's identity including its uid. It is authored fixture data, never derived from the question — the agent reads it as typed state through the UI bridge, so a change in phrasing cannot silently change the screen the model was shown. Journey turns may restate it, which is the fixture saying the operator moved the screen between turns; a turn that does not restate it keeps whatever the previous turn left, including wherever the model navigated itself. Each journey turn publishes both ends of that — interaction and final_interaction — so a reader can see where a turn was asked from and where the model left the screen, without replaying the conversation.

On a live run the authored kube_context is a fixture name, so retargeting replaces it with the context --context connected to. It is null only when the run truly used the kubeconfig's current context; a live row never publishes the fixture's own context name.

Prompt pack and grinding

meta.prompts names the tier pack, any overlays, whether the wording was korvid's own (source), and the digest. source is default when the run used the prompts korvid ships, and override when --tier-pack-file or --prompt-overlay-file changed them.

Effective experimental layers are recorded only in meta.prompts.overlays. meta.policy describes model/tier selection and does not duplicate that field. meta.prompts.overlays is a JSON array of string ids — empty ([]) for a default run, or ["eval-overlay"] when --prompt-overlay-file was supplied (see the meta.prompts example in Run Provenance above).

Both flags are eval-only prompt grinding and both layer after the immutable safety contract, which is always the first text in the composed system message: grinding can change how the model operates, never what it is permitted to do. There is no flag that replaces the whole system prompt.

The eval CLI does not read ~/.config/korvid/config.yaml: configured house rules (agent.rules) affect the running TUI, never this JSON, so a sweep is reproducible from its command line alone. The TUI has no equivalent of these flags — a pack or overlay ships only after the numbers here justify it.

The digest covers what the model actually receives — the composed system message (safety contract, common role, tier pack, overlays, armed capability clauses) plus the complete tool schemas that are retransmitted on every request. Rewording a tool or editing a parameter description therefore changes the digest, because both change the model's input.

Rules that follow from this:

  • a publishable scoreboard row must carry "source": "default"; a run measured under a grind is a tuning artifact, not a comparable score;
  • a prompt experiment reports the baseline and the variant together, from two runs that differ only in the prompt file;
  • a changed digest with an unchanged prompt file means something else moved — a tier change, a catalog change, or a tool-schema edit — and the comparison is void.

What the low tier ships, and what changing it costs

The low tier carries two pieces of shipped wording that a campaign has to hold fixed, because both are inputs to every request the model answers.

The low operating pack (korvid.agent.tiers.low.PROMPT, layer 3) carries the behavioural rules that were tuned against the retained scenario pack rather than written from taste. Each rule answers a failure class those cases reproduce:

rule the case it answers
diagnose from the reason string, never from an exit code alone (137 is not OOMKilled by itself) liveness-probe-failing and oom-killed — the same exit code, different reason
never invent a name or namespace; a 404 means re-list, not retry image-pull-typo
follow the pointer one more hop before answering pvc-pending-no-storageclass, service-endpoints-not-ready
quote the decisive reason string word for word job-backoff-limit-exceeded
ready is not healthy while warnings show probe failures; old restarts are history healthy-deployment, healthy-restart-history, readiness-probe-failing
one bounded tool call at a time the low tier's max_tool_calls_per_iteration: 1

The low tool descriptions (korvid.agent.tiers.low.TOOL_DESCRIPTIONS, versioned by korvid.agent.tiers.low.TOOL_DESCRIPTIONS_VERSION) replace the registry wording for the tools whose shipped description is written for a frontier context window. ModelRouter selects korvid.agent.tiers.low.BEHAVIOR; its tool_schemas() method replaces registry descriptions under these constraints:

  • low routes only. A high route sends the registry description unchanged, so the low tier and the high tier are not measuring the same schema text;
  • by exact tool name. A tool the map does not name — an unmapped registry tool, or one a plugin contributed — keeps the description it declared. There is no prefix, alias, provider or model heuristic;
  • description only. Names, parameters and required fields are never touched, so a rewording cannot widen what a tool accepts;
  • nonempty and at most 250 characters, for every tool the low surface arms, under every read-only/resize/backend combination. The whole schema list is retransmitted on every request, and a 4k-token serving context pays for each character;
  • applied to the deep copy the registry hands out, before the schemas are deep-frozen, so no consumer receives a mutable schema and the registry itself is never mutated.

Both are package-local text: nothing here reads a model name, a provider id, or a network resource to decide what to send.

Changing either is a measurement change, not a copy edit. The digest moves (it covers the composed system message and the schemas), so every published row taken under the old wording stops being comparable. A change lands only with the retained cases above re-run on the same serving provenance, reported as a baseline/variant pair — see Prompt pack and grinding. No score is restated here: this section states constraints and names cases, and the numbers live on the scoreboard.

Outcome and failure class

Each run — and each journey turn — records one outcome (success, failure, error) and, when it was not a success, one failure_class. Both artifacts rank the same four classes in the same order, from one shared helper (korvid.evals.outcome), so a journey row and a scenario row mean the same thing:

failure_class Meaning
safety_violation a write tool call reported success — must never happen
provider_error the turn errored before it produced a graded answer
missing_evidence the answer was not backed by any expected evidence
misdiagnosis evidence was fetched, but the answer was wrong

A journey turn can also fail in ways a single question cannot, and those rank after the four above — a landed write or an errored turn still reads identically in both artifacts:

journey-only failure_class Meaning
malformed_call a call the armed schemas reject (a write attempt is not one: it is published as a write attempt)
forbidden_target the model went back to the resource the user just ruled out
wrong_namespace a call left the namespace the turn's evidence lives in
call_budget_exceeded more calls than the turn's max_tool_calls

Migrating pre-tier campaigns

Campaigns run before issue #316 recorded meta.profile (full or small) instead of meta.policy. The runner that produced them has been deleted, so those fields can no longer be regenerated — the artifacts are still readable, with these equivalences:

Pre-tier field Today
"profile": "small" "tier": "low" — six iterations, one call per iteration, 3,000-character results
"profile": "full" closest to "tier": "high", but the surfaces differ: full offered write schemas and no screen actions
meta.prompts.source unchanged in meaning
(absent) meta.limits, meta.capabilities, meta.tools.armed, catalog_version, per-scenario interaction, per-run outcome/failure_class

Two differences make a pre-tier row not directly comparable to a post-tier row of the same model:

  1. pre-tier runs offered write schemas to the model (safety came from an unarmed executor); the eval environment is now read-only, so no write schema is armed at all;
  2. pre-tier runs dropped every screen action from the surface; the low tier arms open_logs and open_describe, and the model can now drive the eval workspace.

Published pre-tier scores are therefore kept as historical rows and are not restated under a tier label.

Serving Provenance

The prompt fingerprint pins korvid's half of the run. The other half — what answered — is pinned by the meta.serving block the eval CLI writes:

"serving": {
  "model": "qwen3:8b",
  "engine": {"name": "ollama", "version": "0.5.1"},
  "digest": "bbb…",
  "quantization": "Q4_K_M",
  "context_length": 4096,
  "max_context_length": 40960,
  "parameter_size": "8.0B",
  "warmup": true,
  "unavailable": []
}

This exists because the 2026-08-10 matrix did not have it: the deployment served ollama/ollama:latest, nothing recorded which version answered, and by the time the gap was noticed the node was gone (#235). A future campaign that scores differently could not have told a korvid regression from an engine upgrade.

Rules that follow:

  • a publishable row requires an empty unavailable list. Anything listed there — engine, digest, quantization, context_length — is a field the run could not pin, and the CLI warns on stderr when the list is non-empty;
  • publishable rows are measured with --warmup, so the first scenario is not charged for paging the weights in. warmup: false in an artifact means no warm-up happened, including the case where the request failed;
  • context_length is the runtime allocation, not the model's maximum. The two differ by an order of magnitude — Qwen3 advertises 40,960 while ollama may serve 4,096 — and only the allocation was in effect. It comes from /api/ps, which lists loaded models only, so a run without --warmup leaves it unpinned. max_context_length carries the native maximum for context;
  • pin the serving deployment to a released tag. deploy/eval/ollama.yaml is the checked-in manifest and a test rejects :latest. A floating tag makes the recorded version a coincidence rather than a decision;
  • the block is omitted entirely in artifacts written before this capture existed, which is deliberate: absence means "never captured", not "captured and empty".

Measuring the tool surface itself

--without-tool NAME (repeatable) drops a tool from the measured surface. It exists for the controlled arms of #221: diagnose_service and diagnose_pvc were added to the small surface, whose whole premise is a small selection space for 3B-14B models, and that cost was never measured. Deciding it needs the same models, scenarios and prompts with the surface as the only variable.

# arm 2 - the shipped low surface
python -m korvid.evals --model-tier low --json arm2.json

# arm 3 - the same run without the two diagnostics
python -m korvid.evals --model-tier low --without-tool diagnose_service --without-tool diagnose_pvc --json arm3.json

Rules:

  • a name the resolved tier does not arm is refused — a typo, a write tool (no eval ever arms one) or a high-tier-only tool named without --model-tier high would silently measure the unreduced surface and publish it as the reduced arm;
  • the omission reaches the prompt fingerprint, so two arms can never claim the same digest;
  • meta.tools records omitted and count by name. Recovering the arm from the digest alone would mean keeping a lookup table outside the artifact, which is the bookkeeping that made the 2026-08-05 rows unusable;
  • dropping a tool is not a prompt override: prompts.source stays default, because the shipped prompts are still the ones in effect;
  • the tool is removed, not hidden: it is dropped from the resolved policy, so the production tool harness reports it as not armed and the executor is never reached. Withholding only the schema would leave the arm open to contamination, because a model that remembered the tool would still get its answer and the run would credit the call. An omitted call counts as a malformed call — which is itself the measurement, since the question is whether a small model reaches for a tool it was not given.

Interpretation Limits

  • Task success does not prove conversational usability.
  • Offline journeys do not prove Kubernetes fixture realism.
  • One live journey does not cover every interactive workflow.
  • UI calls are recorded headlessly; they prove correct Korvid UI intent, not visual Textual rendering.
  • A model is recommended only when task, offline conversation, and live results are shown separately.

Artifact Retention

Human-readable methodology, scenario descriptions, and scores are versioned with the source code. Generated raw outputs are not:

  • the append-only eval-results branch stores dated compressed artifacts;
  • each run directory includes metadata and SHA-256 checksums;
  • issue #176 links the same artifact directory and contains only summaries;
  • application commits do not carry model transcripts or pull logs.