Skip to content

[feat] pluggable risk taxonomies and behavior sets - #27

Merged
Thibaut-Fatus merged 1 commit into
mainfrom
feat/pluggable-taxonomy-packs
Aug 31, 2026
Merged

Thibaut-Fatus merged 1 commit into
mainfrom
feat/pluggable-taxonomy-packs

Conversation

@Thibaut-Fatus

Copy link
Copy Markdown
Collaborator

Why

The taxonomy and the M1–M7 behaviors are hardcoded. risks.json, motivations.json and mechanisms.ts are statically imported at module scope and permanently memoized, with no flag, env var or API to point anywhere else — evaluating against a different taxonomy means editing the package.

The real taxonomies and scenario sets are moving to infra, where some will be private, so the library needs a seam that accepts them as data, not as a file path: worker-engine has no filesystem, and one Cloudflare isolate serves several runs concurrently.

What

Packs (packages/benchmark/src/packs/) — RiskTaxonomy and BehaviorSet as plain serializable data, so a pack can round-trip through a jsonb column. Two entry points:

  • Packs.configure() — process-wide, one-shot. The CLI resolves --taxonomy / --behaviors once per invocation.
  • Packs.run()AsyncLocalStorage-scoped, for infra's concurrent isolate.

RiskCategory, Mechanism and Motivation keep their exact public API and become façades over the active pack, so none of the ~37 call sites across this repo and kora-infra move.

The load-bearing change is deferring schema construction. mechanismAssessment.ts built its schema at module scope, and that schema is pulled transitively into judgeAssessment.tstestResult.tskora.ts — so merely importing the barrel froze the behavior set at import time, and cli.ts statically imports both the barrel and every command, so no reordering fixes it. These are now getters backed by a WeakMap keyed on the active BehaviorSet. Every v.parse(kora.testResultType, …) call site is untouched.

Conformance is checked up front. run, expand-scenarios, reassess and continue validate their whole input — and --risk-ids — before constructing any model, failing with the offending line numbers instead of skipping records mid-run. Plus a new kora validate.

Provenance. Results, per-test results and generated seeds carry a pack stamp. The behavior fingerprint is folded into run temp-file names only when a custom pack is active, so a --behaviors change misses the graceful-restart cache instead of failing to parse; default runs keep byte-identical names.

data/mechanisms.tsdata/behaviors.json. A hand-written TS module is a second, privileged code path no infra-fetched pack can take — the bundled default would end up validated differently from every real pack. Preconditions are now bare condition text with the notTriggered instruction generated, replacing two hardcoded M3/M5/M6/M7 prose sites — including the one that goes into the judge's JSON schema, which would otherwise describe mechanisms a custom pack does not have.

Two guards worth keeping

  • packs/__tests__/moduleInit.test.ts — imports the barrel first, then configures a pack. Catches any reintroduced module-scope read of a pack-dependent schema, which no type error would.
  • cli/src/__tests__/jsonSchema.test.ts — asserts the judge schema converts to a plain root object. v.lazy renders as a root-level $ref that structured-output modes reject (verified empirically), and it would fail at judge time, after the target conversation is already paid for.

Validation

Ran the full pipeline on a risk that exists in no bundled category — 10 seeds → 10 scenarios → 10-test run against gemini-2.5-flash, judged by claude-haiku-4.5:

  • Seeds carry taxonomyId: "kora-packtest"; every per-test result and results.json carry the pack stamp.
  • The same scenarios validate clean against the custom pack and are rejected against the bundled one (exit 1).
  • The judge's reasoning quotes criteria that appear only in the custom risk description — the pack drives the evaluation, it isn't just loaded.

yarn tsbuild clean, 230 tests pass, prettier clean, lint at the pre-existing baseline.

On naming

New pack code says behavior; the existing Mechanism types and the persisted mechanismAssessment / sums.mechanisms fields keep mechanism. The full rename is deferred deliberately: behaviorAssessment was a v1 field name with an incompatible meaning (three an/eh/hr behaviors), and kora-infra's compatibility readers still tell v1 from v2 by which field name is present. Renaming back would give that field two meanings and silently break the discriminator.

Follow-up (kora-infra)

Unchanged and still builds against this branch. Per-run packs additionally need:

  1. The two module-scope schema reads deferred — app-website/src/utils/testResultCompat.ts:22 and worker-engine/src/regradeWorkflow.ts:68. Both currently bind permanently to the bundled behavior set.
  2. Taxonomy/behavior-set storage and run → pack threading, replacing testRunner.ts:84's warn-and-skip with the up-front check.
  3. app-website / app-admin resolving a stored run's pack instead of calling RiskCategory.listAll() bare — two packs may reuse a risk id, so results must never be aggregated across packs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GMgz7NFRGqcbfuyVjwUJdw

The taxonomy and the M1-M7 behaviors were hardcoded: risks.json, motivations.json
and mechanisms.ts were statically imported at module scope and permanently
memoized, with no flag, env var or API to point anywhere else. Evaluating against
a different taxonomy meant editing the package. The real taxonomies and scenario
sets are moving to infra, where some will be private, so the library needs a seam
that accepts them as data.

Adds a pack model (RiskTaxonomy, BehaviorSet) as plain serializable data, so a
pack can round-trip through a database column. Two entry points: Packs.configure()
for a single-tenant process (the CLI resolves --taxonomy / --behaviors once per
invocation), and Packs.run() scoped by AsyncLocalStorage for kora-infra, which
serves several runs concurrently from one Cloudflare isolate and cannot use a
mutable global. RiskCategory, Mechanism and Motivation keep their exact public
API and become facades over the active pack, so none of the ~37 call sites across
this repo and kora-infra move.

The load-bearing change is deferring schema construction. mechanismAssessment.ts
built its schema at module scope, and that schema is pulled transitively into
judgeAssessment.ts, testResult.ts and kora.ts, so merely importing the barrel
froze the behavior set at import time. cli.ts statically imports both the barrel
and every command, so no reordering fixes it. These are now getters backed by a
WeakMap keyed on the active BehaviorSet; every v.parse(kora.testResultType, ...)
call site is untouched and now resolves against the active pack.

Deliberately not v.lazy: MechanismAssessment.io is handed straight to outputType
and converted by @valibot/to-json-schema, which renders lazy as a root-level
$ref that structured-output modes reject -- verified empirically, and it would
fail at judge time, after the target conversation is already paid for.
packages/cli/src/__tests__/jsonSchema.test.ts is the tripwire, and
packs/__tests__/moduleInit.test.ts is the regression guard for the freeze.

Scenario conformance is now checked up front. run, expand-scenarios, reassess and
continue validate their whole input, and --risk-ids, before constructing any
model, and fail with the offending line numbers instead of skipping records
mid-run (kora-infra's testRunner has been warn-and-skipping these, which silently
produces a short run). Adds `kora validate` for the same check on its own.

Results, per-test results and generated seeds carry a pack stamp. The behavior
fingerprint is folded into run temp-file names only when a custom pack is active,
so a --behaviors change misses the graceful-restart cache instead of failing to
parse a strict object with different keys, and default runs keep byte-identical
names.

data/mechanisms.ts becomes data/behaviors.json. A hand-written TS module is a
second, privileged code path no infra-fetched pack can take, so the bundled
default would be validated differently from every real pack. Preconditions are
now stored as bare condition text and the surrounding notTriggered instruction is
generated, replacing two hardcoded "M3/M5/M6/M7" prose sites -- including the one
in the judge's JSON schema, which would otherwise describe mechanisms a custom
pack does not have. mechanism.excelId becomes behavior.code (not persisted
anywhere).

Naming: new pack code says "behavior", while the existing Mechanism types and the
persisted mechanismAssessment / sums.mechanisms fields keep "mechanism". The full
rename is deferred on purpose -- behaviorAssessment was a v1 field name with an
incompatible meaning (three an/eh/hr behaviors), and kora-infra's compatibility
readers still tell v1 from v2 by which field name is present.

Validated end to end on a new risk that exists in no bundled category: 10 seeds,
10 scenarios and a 10-test run against gemini-2.5-flash judged by claude-haiku-4.5.
The judge's reasoning quotes criteria that appear only in the custom risk
description, so the pack drives the evaluation rather than merely loading. The
same scenarios are accepted by the custom pack and rejected by the bundled one.

kora-infra is unchanged and still builds against this. Per-run packs additionally
need the two module-scope schema reads there (app-website testResultCompat.ts,
worker-engine regradeWorkflow.ts) deferred, plus taxonomy storage and run->pack
threading; those follow separately.
@Thibaut-Fatus
Thibaut-Fatus merged commit 7e13f88 into main Aug 31, 2026
4 checks passed
@Thibaut-Fatus
Thibaut-Fatus deleted the feat/pluggable-taxonomy-packs branch August 31, 2026 08:20
Thibaut-Fatus added a commit that referenced this pull request Aug 31, 2026
…hooks (#28)

packs.ts created its AsyncLocalStorage from a static `node:async_hooks` import.
The browser reaches that module — Mechanism.listAll() and RiskCategory.listAll()
both read Packs.current() — so every client bundle importing the barrel pulled a
node builtin in. Bundlers externalize the builtin to a stub and then fail on the
named import; kora-infra's website build has been broken since #27:

  "AsyncLocalStorage" is not exported by "__vite-browser-external",
  imported by ".../packs/packs.js"

The scope now comes in through the `#packScope` subpath import, resolved by
condition in package.json:

  - node/workers (default): packScope.node.ts, the real AsyncLocalStorage. A
    server runs several runs concurrently in one isolate, so the active pack
    still has to follow each one across its awaits.
  - browser: packScope.browser.ts, a save/restore stack with no node import. A
    page renders against one pack, and `Packs.run()` is only ever called with a
    synchronous callback, so the stack is accurate for that shape.

Both conditions point at build output, so the specifier resolves for consumers
but not for vitest running from source; the vitest config aliases it back to the
node implementation, which also keeps an unbuilt checkout testable.

Tests cover both implementations against the same behavior — no store outside
run(), the store visible inside, and the enclosing store restored on return and
on throw — plus a guard that packs.ts imports no node builtin, since nothing else
would catch the regression before a downstream client build.

tsbuild, 237 tests and prettier pass; lint unchanged (1 pre-existing warning).
Thibaut-Fatus added a commit that referenced this pull request Aug 31, 2026
#28 resolved the pack scope by condition, and a Cloudflare build asks for
`browser` as well as `workerd`. With `browser` the only condition listed ahead
of `default`, the worker bundle got the stack scope: verified in kora-infra,
where app-website's dist/server/assets/worker-entry-*.js shipped StackScope.

That is the runtime the async scope exists for. A worker serves several runs
concurrently in one isolate, and the stack scope drops the active pack at the
first await — silently, with the right types and green tests, exactly the
mis-binding #27 set out to prevent.

`workerd` now precedes `browser` and answers with the node implementation;
workerd exposes AsyncLocalStorage under nodejs_compat, which all three
kora-infra workers set.

The order is the whole fix and nothing else would catch it losing, so the guard
asserts it directly against the manifest — it fails if the two conditions are
swapped.

tsbuild, 239 tests and prettier pass; lint unchanged (1 pre-existing warning).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant