Generic QA patterns live in the
/qaskill. This file holds project-specific overrides only. Packaging QA (wheel/clean-venv E2E,uv lock --checkhook, pyright-outside-venv, import-smoke, CI-Windows-path-test) is in the skill's Packaging QA section — referenced, not duplicated, below.
Python library/CLI — no web endpoints, no K8s deployment to verify browser flows against. GUI server is local dev tool only (binds 0.0.0.0 intentionally).
Applicable:
- Tests:
make test— 225+ integration tests, 30%+ coverage, no mocks - Lint:
make lint— ruff (lint + format + import sort + bandit security rules), pyright strict - Pre-commit: full suite including gitleaks, semgrep, vulture, detect-secrets
- Code review: manual diff review for prompt/config changes
- Ollama smoke:
make discover-ollama+ at least one slim-config run viacertamen --config /tmp/cert-*.ymlto catch provider-prefix and slim-schema regressions - Workflow integration:
certamen workflow validate+certamen workflow executeover EVERY YAML underexamples/workflows/ANDsrc/certamen/workflows/— validate-only is insufficient (catches schema typos but not runtime issues like wrong property names, missing model providers, empty outputs) - GUI QA (Playwright MCP): build
cd frontend && npm run build && npm run lint, startcertamen gui --port 8765withCERTAMEN_SKIP_AUTH=true, navigate tohttp://localhost:8765/, exercise Workflow Editor + Tournament Results tabs, watch console errors and/api/runscontent (question/champion render)
Not Applicable:
- Schemathesis / ZAP / autoqa: no HTTP API surface intended for external traffic (the GUI server is dev-only, binds 0.0.0.0 intentionally, has no OpenAPI). The crawler / accessibility checks are out-of-scope until/unless we ship the GUI as a hosted product.
- K8s logs: deployment is simple container, no complex orchestration
- SonarCloud: still ACTIVE and gating PRs (project key
nikolay-e_arbitrium-core, runs as a requiredSonarCloud Code Analysischeck). It uses Automatic Analysis (nosonar-project.properties), so exclusions are set via the web UI /POST /api/settings/set. Quality gate flips to ERROR on a single new BUG (new_reliability_rating1→3). Common trap in tests: S1244 "Do not perform equality checks with floating point values" — evenx == 0.0on a literal-zero price fires; use== pytest.approx(0.0, abs=1e-12). (Coverage via pytest-cov, security via ruffS*+ bandit + CodeQL, secrets via gitleaks/detect-secrets also run — Sonar overlaps but is NOT retired.)
- S104 (ruff/bandit) in web server/CLI — intentional bind to 0.0.0.0, suppressed with
# noqa: S104. - pip-audit audits its OWN hook environment, not certamen's deps (the venv/uv.lock). CVEs in pip-audit's transitive deps (e.g.
pipitself, ormsgpackviacachecontrol) surface here even though they're absent from certamen's supply chain — add the GHSA to the hook's--ignore-vulnlist in.pre-commit-config.yaml. Do NOTuv lock --upgrade-package <pkg>for these (the package isn't in uv.lock, so it triggers a full-lock re-resolution churn instead). - Intentional bcrypt dummy hash (timing-attack prevention) needs
# pragma: allowlist secretfor detect-secrets. - JSONC files (tsconfig with comments) must be excluded from
check-jsonhook. - markdownlint on generated reports/docs: exclude
benchmarks/reports/anddocs/dirs. - After package rename, search BOTH
.github/workflows/andMakefilefor stale source paths —--cov=src/<old_name>causes "0.00% coverage" failure on CI while passing locally. - Docker builds don't follow symlinks — always use real files for anything copied in Dockerfile.
Web interface (interfaces/web/) and logging infrastructure (shared/logging/) contribute 0% coverage in CI as they require runtime; exclude them from coverage.omit. Threshold of 30% is correct for integration-test-only project.
- GUI server requires
OLLAMA_BASE_URLenv to actually run workflows that include Ollama models;CERTAMEN_SKIP_AUTH=truefor dev (skips bcrypt + DB init). - Workflow Editor's WebSocket connects to
/wsfor live execution events; if backend uses lazy auth import path it must also bypass whenCERTAMEN_SKIP_AUTH=true.
- Use
venv/bin/certamennot systemcertamen(system binary may point to old package name). - Always set
OLLAMA_BASE_URL=http://localhost:11434. - qwen3 models with thinking mode: use
gemma3:1bas substitute in QA config — qwen3 fillsmax_tokenswith<think>...</think>blocks leaving no room for actual answer. - Run every example workflow at least once per
/qapass:for wf in examples/workflows/*.yml src/certamen/workflows/*.yml; do certamen workflow execute "$wf"; done. Validate-only ran green for bothmulti-model-comparison.ymlandprompt-template.ymlwhile runtime silently produced empty outputs — only end-to-end execution surfaced the brokenpages:/mode:properties. diamond-tournamentrequires 4 LLM nodes: slim config must declare exactly N models where N matches the workflow'ssimple/llmnode count. Mismatch errors loudly (expects N models, got M) — good. Fordiamond-tournamentuse 4 entries; reuse the same model under different keys (gemma3_1b_a/gemma3_1b_b) when only 1–2 local models are available.diamond-tournament.ymlis the heavy full-pipeline E2E and runs ~15+ min when all 4 models genuinely generate (iterative diverge→converge,gate max_rounds: 6, peer-review + interrogate + synthesis + knowledge-map). Its shippedModel C: ollama/qwen3:4bthinking-modes to an empty response → emits a non-fatalWARNING [base] Model generation returned error responseand Model C contributes nothing; the tournament still completes and picks a champion (graceful). qwen3's empty short-circuit is why it appears to finish quickly. Swapping Model C to a generating model surfaces a second trap: weak 3B judges (orca-mini) returnapology/refusal instead of evaluation(logged ERROR, scoring continues). For a FAST full-pipeline E2E check usetournament-elimination.yml(completes with a champion in ~1 min); reservediamond-tournament.ymlfor when you can wait. Changing the shipped Model C roster is a curation decision, left to the maintainer.- Per
slim.pyschema: extra top-level keys are forbidden.judges:does NOT exist; judges are defined inside the workflow's tournament/judge node. The slim config holds ONLYworkflow,question,models,overrides,price_overrides,secrets,outputs_dir,logging(seeSlimConfiginsrc/certamen/infrastructure/config/slim.py).price_overridesis a map ofmodel_name → {input_per_1m, output_per_1m}consumed only bycertamen cost.
certamen cost --config X.ymlprints a bounded (min/expected/max) per-model, per-stage estimate WITHOUT running the tournament. Prices come fromlitellm.model_cost(+ configprice_overrides, fail-loud on unpriced,ollama→$0). Reusesload_and_materialize, so it also catches slim-config errors (e.g. model-count vssimple/llm-node mismatch) for free.- The executor re-runs the whole forward DAG every gate iteration (
async_executor.pywhile True, no memoization; the CLI path builds models without a response cache). So divergence (generate/interrogate/diverge_improve) re-fires each round: a healthy 4-model diamond logs ~144 LLM calls over 4 iterations, not ~36. Any cost/call-count reasoning must account for this ×(iterations) multiplier. - Deterministic $0 pipeline E2E:
tests/integration/test_e2e_tournament_pipeline.pymonkeypatchesLiteLLMModel.from_configto return a stubBaseModel(parseable scores for peer_review, numbered questions for interrogation) and drives the realAsyncExecutorover the materialized diamond graph — asserts interrogation insights flow forward and synthesis/knowledge_map are non-empty, no API keys, no ollama. Use this pattern to verify graph/wiring changes without burning tokens or waiting ~15 min on ollama. diamond-tournament.ymlnow runsinterrogate.rounds: 3(relentless multi-round follow-up interrogation) — this multiplies interrogation calls ~3× and lengthens real runs. Interrogation output flows via a newinsightsSTRING output ontournament/interrogateintodiverge_improve(the old edge asked for a non-existent handle and silently dropped it). Finalization (synthesize/knowledge_map) readsdiverge_improve.improved(always populated) rather thanconverge_improve.improved(empty on the terminal iteration);knowledge_mapreuseschampionas its judge model (a dedicatedgate→judgeedge trips cycle detection).
simple/textnode: properties aretexts: [str, ...],separator: str, and (internal)pages,current_page,hidden. Putting seed input intopages:instead oftexts:produces silent empty outputs —TextNode.execute()only readspageswheninput_textis connected. Symptom: workflow validates AND executes successfully, all LLM nodes get empty prompts, allsimple/textoutput nodes showoutput_text: "". Seen onexamples/workflows/multi-model-comparison.ymlandexamples/workflows/prompt-template.yml. Fix: convertpages: [["seed text"]]+mode: append→texts: ["seed text"]+separator: "\n". Themode:property does NOT exist onsimple/text(only onflow/gate).simple/llmmodel_name MUST include provider prefix when provider isollama:model_name: ollama/gemma3:1b, notmodel_name: gemma3:1b. Without the prefix, LiteLLM raisesBadRequestError: LLM Provider NOT providedand the model returns an error response.LiteLLMModel._validate_required_fieldsnow auto-prepends the prefix forollamaprovider — but example workflows should still write the prefix explicitly for clarity.
scripts/discover_ollama_models.py(themake discover-ollamacommand) once generated slim configs with model_name missing theollama/prefix AND with legacytournament:/knowledge_bank:keys thatSlimConfigrejects. Now fixed — generated config matches slim schema and usesollama/<name>everywhere.ExceptionClassifiershort-substring collision: a substring error-pattern (e.g."tps") matched insidehttps://in everyBadRequestError(whose message carries adocs.litellm.aiURL) → misclassified permanent config bugs as retryable rate-limits → infinite retry. When adding error-pattern strings: anything under ~5 chars MUST be space-delimited or word-bounded;BadRequestErroris mapped tobad_request(non-retryable) in_EXCEPTION_TYPE_MAP.- Logged errors should include the actual error string, not the wrapper object.
ModelResponsehad no__repr__, sologger.warning("... %s", response)printed<ModelResponse object at 0x...>. Now logsmodel=<name> error_type=<type> error=<text>ANDModelResponse.__repr__returns the same shape for defensive logging.
tournament_startedMUST includequestionandmodelsin payload — the Tournament Results sidebar in the GUI reads them via_apply_event_to_run_summaryininterfaces/web/runs.py. Without these, the sidebar shows(no question)and the run is unidentifiable in a list of similar runs.tournament_endedMUST includechampion: {name, model_name, provider}— extracted from output nodes whose payload contains{"champion": {"model": {...}, "response": "...", "rankings": [...]}}(matches thetournament/championnode schema). The summary stores the friendlyname, falling back tomodel_name.- Both fields are emitted from
_execute_workflow_dictininterfaces/cli/main.pyvia_extract_workflow_question/_extract_workflow_model_keys/_extract_champion_from_outputs. Any future workflow that wants its run to be identifiable in the Runs sidebar must use asimple/textnode withid: "question"(the slim_loader convention) and anysimple/llmnodes'properties.namewill appear as the model labels.
- After any frontend change, run
cd frontend && npm run build && npm run lintBEFORE manually testing.tsc -b(insidenpm run build) is stricter thantsc --noEmitand catches type errors thatnpm run lintmisses. - The shipped
frontend/index.htmlMUST include a<link rel="icon" ...>— absence causes a console-visible404 /favicon.icoon every page load. Use inline SVG data URL (e.g.,🏆glyph) to avoid an asset round-trip. - Health endpoint is
/healthNOT/api/health(see_setup_routesinserver.py:248). Other routes ARE under/api/:/api/models,/api/nodes,/api/runs,/api/runs/{id},/api/runs/{id}/events, plus/api/runs/{id}/attach(websocket). CERTAMEN_SKIP_AUTH=trueis REQUIRED for local QA — without it, the GUI bootstrap tries to loadbcrypt/JWT modules and may fail on env withoutJWT_SECRET_KEY/ DB.OLLAMA_BASE_URL=http://localhost:11434is also needed for the GUI to populate the model dropdown via/api/models; otherwise the editor shows a barebones model list (LiteLLM static fallback only).
tournament/rank→extract_insights.modelflow: rank node outputs model config as dict;ExtractInsightsNodemust callensure_single_model_instance()before.generate().flow/gate→synthesize.modelflow: gate returns champion as raw dict;SynthesizeNodemust callensure_single_model_instance()— same pattern asExtractInsightsNode.- Pattern: any workflow node accepting a
modelorchampioninput fromgate/ranknodes must handle dict input viaensure_single_model_instance()before callingsafe_generate(). - Executor termination:
_is_iteration_donemust check ALLnode_outputsentries (not just last layer tasks) — gate node in early layers was not detected otherwise. - Small models (1B–4B) as judges: rankings will be empty (can't produce parseable
LLM1: X/10scores); expected behavior, not a bug — tournament still terminates and produces synthesis. simple/llmPROPERTIESschema must declare every key_build_model_configreads.system_promptandweb_search_optionsare read fromnode_propertiesand applied, but were absent fromLLMNode.PROPERTIES→BaseNode._validate_propertiesloggedUnknown properties {'system_prompt'}on EVERY workflow using personas (knowledge-bank, tournament-elimination, chain-of-thought, diamond-tournament), even though the property worked at runtime (the validator only warns, never strips). The schema is also what the frontend editor renders from/api/nodes, so an undeclared-but-used property is invisible in the UI. Rule: any property the node'sexecute/_build_model_configconsumes MUST be inPROPERTIES.
- In periodic loop: always
raise, notbreak. - In shutdown (task we explicitly cancelled): comment OK.
- At top level: use
finallyonly.
- aiohttp's typed handlers require
async defeven when noawaitis needed (signature isCallable[[Request], Awaitable[StreamResponse]]). Reverting to sync to satisfy a "no awaits" lint triggers strict type failures ininterfaces/web/(3 inruns.py, 4 inserver.py). websocket_handlerreturns eitherWebSocketResponseon success orResponse(status=403/503/429)for pre-upgrade rejections. Annotate return asweb.StreamResponse(parent of both) — notWebSocketResponse.- Web/auth runtime deps (
aiohttp,bcrypt,PyJWT,psycopg2) live in theguioptional-extra (pip install certamen-core[gui]/uv sync --extra gui). pyright resolvespsycopg2via typeshed stubs;bcrypt/jwtcarry inline# pyright: ignore[reportMissingImports]because the default dev env (uv sync --extra dev) does not install them.
biome migrate --writemis-convertslinter.rules.recommended: true→preset: "none"on biome 2.5.0 — i.e. it DISABLES every rule (the opposite of intent), whilebiome cistill exits 0, so the regression is invisible. The correct value ispreset: "recommended". After a biome bump, hand-fix the config: bumpbiome.json$schemato the installed version AND setrules.presetto"recommended"yourself — never trustmigrate's output. Reproduces with and without alinter.domainsblock. Filed upstream: biomejs/biome#10716.- deptry config:
pep621_dev_dependency_groupsis deprecated (deptry ≥0.25) → useoptional_dependencies_dev_groups. Surfaces as a non-fatal warning inmake lint/CI; bump the key in[tool.deptry]when seen.
.pre-commit-config.yamlastral-sh/ruff-pre-commitrev must stay aligned withpyproject.toml's ruff constraint (currently>=0.15.10,<1.0).ruff formatoutput can differ between minor versions, so a stale pre-commit rev makes CI pre-commit and localmake lint(venv ruff) disagree. Bump theruff-pre-commitrev whenever the pyproject ruff pin is upgraded. (Ruff replaces black + isort + pyupgrade, so there is a single formatter/linter version to keep in sync instead of three.)
- Branch-protection required checks MUST mirror the CI job matrix. Moving the
macOS/Windows test matrix out of CI (
ci.yml) into release-only (cd.ymlpre-release-tests) leftTest (Python 3.12 / macos-latest)andTest (Python 3.12 / windows-latest)as required contexts that CI no longer reports — every PR then sits inBLOCKED/"Expected, waiting for status" forever. Fix withgh api --method PATCH repos/<o>/<r>/branches/main/protection/required_status_checkspassing only the contexts CI actually emits. - Dependabot must use the
uvecosystem, notpip. This project is uv-managed (uv.lock + theuv-lockpre-commit gate). Thepipecosystem edits pyproject.toml constraints but never updates uv.lock, so every Python dep PR is bornBLOCKEDon the uv-lock check. Theuvecosystem updates pyproject + uv.lock atomically. Frontend deps need a separatenpmecosystem entry (directory: /frontend) — without it the frontend only receives repo-level security PRs, not version bumps. diff-context.ymlreferenced a nonexistentnikolay-e/treemapper-actionreusable workflow → 0s "workflow file issue" failure on every PR. There is no published diff-context GitHub Action; the diff-context review is a local CLI step (diffctx . --diff …). The workflow was removed.- pre-commit pyright
additional_dependenciesomits the gui/auth runtime deps (psycopg2-binary,bcrypt,PyJWT). So CI's pre-commit pyright cannot type-checkinterfaces/web/auth/*and stays green whilemake lint(run with.[dev,gui]installed in the venv) surfaces strict errors there (e.g.reportUnnecessaryComparisonon a defensivegetconn() is Noneguard). Runmake lintwith the gui extra installed to catch what CI misses; suppress genuinely-defensive-but-stub-dead guards with a targeted# pyright: ignore[reportUnnecessaryComparison].
- Type check is pyright (strict), not mypy.
Unknown*reports are disabled for untyped libs (litellm/sklearn) to mirror the oldignore_missing_imports;pythonVersion = "3.11"matchesrequires-python. See/qaskill: Packaging QA — barepyrightoutside the venv resolves the wrong interpreter and floods false errors; run viamake lintinside the venv. uv lock --checkis a pre-commit hook (astral-sh/uv-pre-commit, iduv-lock) — see/qaskill: Packaging QA. Any dependency edit MUST be followed byuv lockor this gate fails.- deptry guards dependency completeness (CI
testjob +make lint). Config:devis a dev-group;PyJWT→jwt/psycopg2-binary→psycopg2name-map;tiktoken/aiofiles/google-authareDEP002-ignored (litellm runtime extras we never import directly). - import-smoke test (
tests/integration/test_import_smoke.py) walks every module — needs theguiextra installed AND setsCERTAMEN_JWT_SECRET(the auth package hard-fails at import without a ≥32-char secret). CItestjob installs.[dev,gui]. See/qaskill: Packaging QA (import smoke). - CI Windows path test: use
set.issubset(set(path.parts))not"src/certamen/workflows" in str(path)to avoid backslash separator failure (see/qaskill: Packaging QA). - Frontend is gated in CI (
Frontendjob:biome ci+tsc -b+vitest run). Vitest tests exercise real behavior (real store viagetState, real hooks viarenderHook) — no mocks. - codespell skips lockfiles (
package-lock.json,uv.lock) — hash fragments trip false positives. - A standalone
.importlinterfile takes priority overpyproject.toml's[tool.importlinter]section. A legacy.importlinter(ini-format, from the certamen-framework merge) referenced modules that no longer exist (certamen.config,certamen.models,certamen.certamen), solint-importscrashed withModule 'certamen.config' does not existon every run — silently, because CI'sarchitecture-checksjob iscontinue-on-error: trueand swallows any failure into::warning::Architecture violations detected. This meant the correct, current layer-boundary contracts already defined inpyproject.toml(domain/ports/shared independence, Clean Architecture layering) were never actually enforced, for as long as the stale file existed.lint-imports --verbosenames the exact contract/module it chokes on — check for a stray.importlinterfile whenever that error references a module that doesn't exist insrc/. Deleted; contracts now run for real (4 kept, 0 broken). - After any frontend dependency bump that includes
@biomejs/biome,frontend/biome.json's$schemaURL must be bumped to match — otherwisebiome ciin CI (which installs the new version vianpm ci) emits "configuration schema version does not match the CLI version" every run. Not a hard failure, but a recurring warning; bump the schema URL as part of the same commit as the dependency bump.
Display.print()(interfaces/cli/ui.py) force-encoded all colored CLI output to ASCII (text.encode("ascii", errors="replace")), turning every non-ASCII character — arrows in workflow names/descriptions (Diverge → Converge), emoji in LLM responses, non-English names — into a literal?. Reproduces even on a UTF-8 terminal/locale; verify withcertamen workflow show <name> | xxdand grep for3fwhere a multi-byte UTF-8 sequence should be. Fix: encode withsys.stdout.encodinginstead of hardcoded"ascii". Onlycli_cyan/cli_error/cli_success/cli_warning/cli_info(all routed throughDisplay.print) were affected; rawprint()call sites (e.g. workflow execute's output dump) were already fine.- Wiring a
gate/eliminatechampion (raw model config dict) into asimple/textoutput node dumps every internal field (system_prompt,context_window,base_url,temperature, ...) instead of showing the model name — same bug class as the previously-fixed champion-stringification findings (shared.mapping_utils.model_display_name), but inTextNode._to_page's dict-formatting branch, which the earlier fix pass didn't touch. Reproduces by runningexamples/workflows/tournament-elimination.ymlend-to-end and inspectingchampion_output. Fix: when the dict has bothmodel_nameandproviderkeys (the model-config shape), render viamodel_display_name(); otherwise keep the existing key/value dump (needed for generic debugging output). certamen workflow validatenever checked node types against the registry — onlyWorkflowLoader.load_from_file's YAML-schema check ran, so a workflow with a typo'd/unknowntype:on a node reported "Workflow is valid" (exit 0) and only failed later, with a different and less discoverable error, atworkflow execute. Defeats the entire purpose of a pre-flight validate command. Fix:_validate_workflow_filenow cross-checks every node'stypeagainstcertamen.application.workflow.registry.registry.get(...)and fails validation with the offending type name(s) if any are unregistered.scripts/discover_ollama_models.pyused Ollama's/api/tagsdetails.familyfield (e.g."llama") asdisplay_name, not the actual model tag — since many unrelated models share an architecture family (Yi, Llama, Mistral-derived models all commonly report"llama"), generated configs had duplicatedisplay_names across genuinely different models (verify:grep display_name: config.yml | sort | uniq -c | sort -rn), breaking champion/report disambiguation for anyone usingmake discover-ollamawith more than a couple of local models. Fix: use the actualmodel["name"](the Ollama tag) instead.
- A back-edge in a workflow graph is NOT rejected as a cycle — the executor classifies it as a bounded feedback loop (the mechanism behind tournament gate-loops) and runs the iteration loop up to
max_iterations(default 20), then stops with normaloutputs.GraphValidationError("Graph contains a cycle")only fires for a cycle within a single execution layer (non-feedback). So the safety property to assert for a cyclic graph is termination (returnsoutputs, no hang), not an error. Seetests/integration/test_executor_gaps.py. simple/textreadstexts:only when noinput_textis connected; seed text placed inpages:is silently ignored → emptyoutput_text(validates + "runs" green). Covered bytest_executor_gaps.py.
- Tournament-results WS reconnect storm:
TournamentView's attach-WebSocketuseEffectmust depend ONLY onselectedId. IncludingliveStatus(which the effect itself sets to connecting→live) re-runs the effect on every status change → close+reopen WS forever (192+WebSocket closed before connection establishedwarnings on opening any run). The// eslint-disable react-hooks/exhaustive-depsmasked it. Use a functional update inonclose(setLiveStatus(prev => prev === "live" ? "ended" : prev)) instead of readingliveStatus. - React error #31 on champion:
tournament_ended.payload.championis{name, model_name, provider}(object) for current runs, but older runs emit a string. Rendering it directly crashes the run-detail subtree. Extract a display string (typeof === "string" ? it : c.name ?? c.model_name) before assigning to a rendered field. (Reconnect storm was masking this until the WS stabilised.) - Editor fires ~1
POST /api/validateper node on load (28 for diamond-tournament) — all 200 but a chatty re-validate; debounce opportunity, not a bug. - Local
certamen guishows versionv0.0.0-placeholder(theGIT_SHAsed runs only at Docker build) — expected, not a finding. - Completed-run detail stuck on "● LIVE" + 10-min idle WS. Opening a finished run:
attach_run_websocketreplays all buffered events (incl.tournament_ended) as{type:"event"}but_replay_eventsnever sent{type:"ended"};_tail_eventsonly sends "ended" fortournament_endedin new events, so for an already-complete run it idledidle_timeout=600sbefore closing → status frozen at "live" and a 10-min hung WS per opened run. Fix:_replay_eventsreturns analready_endedflag; the handler sends{type:"ended"}and closes immediately when the replay already containedtournament_ended. - PhaseStrip showed "Waiting for tournament to start…" forever on workflow runs. The strip is built on
phase_started/phase_completedevents, but the workflow executor emits node-level events (node_start/node_complete/iteration_start), never phase events — soallPhasesis always empty. Fix: when atournament_startedevent exists but no phase events, render nothing instead of the stale "waiting to start" message.
Generic QA patterns live in the /qa skill — do not duplicate here.