ADR-0006: LandscapeClass / DiscoveryRunContext split, and the cross-deck gate¶
Status: Accepted
Date: 2026-07-20
Driver: Khalil
Related: ADR-0005 (owns discovery_modality; this ADR splits its internals along a class seam), PR #176 (Inc 0 — relocation), feat/landscape-class-adapter (Inc ½ — the adapter), .claude/skills/landscape-loop-gt/SKILL.md + .claude/skills/landscape-loop-explore/SKILL.md (where the class contract previously lived by convention only)
Context¶
ADR-0005 specified the sponsor/asset discovery harvester and built it against one modality: RNAi. It worked — cardiometabolic-rnai-001 and later obesity-rnai-001 both reached their recall gates, and the second deck reused the first's machinery unchanged.
Two later runs stressed the assumption that "modality" was a parameter rather than a hardcode:
- Polygon PLG-101 (anti-CD8 mAb, acute-MI reperfusion) — an antibody thesis. Open Targets' disease→target expansion is wrong for a mechanism-anchored antibody; the modality-token matcher needed stem matching; the patent by-target fan-out had to be gated to batched backends.
- GNS561 (small-molecule autophagy inhibitor) — concept queries had to key on modality × indication rather than modality × gene, and CORE required a company-grade evidence gate.
Each run reached for the same escape hatch: edit the shared module, or fork it. Both are wrong. Editing shared code makes every prior deck a regression risk; forking duplicates the matcher, which the "never fork the matcher" invariant exists to prevent.
The deeper problem was that the RNAi assumptions were invisible. Nothing in the code said "this gene-alias map is RNAi's" or "this CDE term list is cardiometabolic" — so a new landscape silently inherited them and produced plausible, confidently wrong output. The architecture review demonstrated three concrete corruption paths: antibody records filtered out by the RNAi modality gate, derived signals stamped cardiometabolic-rnai-001, and CDE searching an unrelated lipid/chemical scope.
This ADR ratifies the abstraction introduced to make those assumptions explicit and refusable. It records decisions already implemented across Inc 0–3; it is a ratification, not a proposal.
Decision¶
Decision 1 — Two objects, split by lifetime: LandscapeClass and DiscoveryRunContext¶
ogur/engine/discovery_modality/classes/base.py defines both.
LandscapeClass holds what is true of a modality, for every landscape of that modality, forever: modality tokens and the OT MoA keyword filter, the gene-alias map and Ensembl seed, the decompose prompt, the target-expansion spec, matcher config (CPC prefixes, patents backend), per-source policy (SEC forms, CDE policy), and behavioural protocols (plan_round1, classify_scope_fit, matcher, assemble).
DiscoveryRunContext holds what is true of one run: landscape_id, the resolved klass, the run's ScopeSpec, a LandscapeConfig (targets / min_target_count / source_filters), derived source_capabilities, the alias resolver and its dataset version, and the cache.
Why the split, rather than one object. One rnai class serves two landscapes. cardiometabolic-rnai-001 and obesity-rnai-001 share every piece of modality vocabulary — the same siRNA tokens, the same gene aliases, the same decompose prompt — and differ only in their target list, min_target_count, and source_filters. Had those per-landscape knobs lived on the class, a second deck of the same modality would have required a second class, duplicating the vocabulary and letting the copies drift apart. The rule that falls out: wanting a second adapter for a second deck of the same modality is the signal that landscape config has leaked into the class.
landscape_id is the sharpest case. A class is reused across landscapes by construction, so a landscape_id reachable from the class would necessarily be wrong for every landscape but one — and it is the field that stamps derived signals. It therefore lives on the run context, whose __post_init__ rejects an empty landscape_id rather than defaulting it: an unstamped signal pool is recoverable, a mis-stamped one silently pollutes another landscape's Monitor view.
Consequence — capability becomes computable. Because class and scope meet in one place, registry.derive_source_capabilities can report per-source coverage as a function of both. The CDE axis is the worked example: with no cde_policy, or with scope indications that map to no registry term, CDE is marked unsupported with a reason. Previously it fell back to three hardcoded lipid terms and searched a cardiometabolic scope regardless of the thesis, manufacturing plausible off-domain sponsors. Reporting no coverage is strictly better than manufacturing wrong coverage.
Consequence — cache keys must be class-aware. LandscapeClass.config_digest() hashes only result-affecting config, and feeds DiscoveryRunContext.fingerprint(). Without it, two classes issuing the same query string would collide in the result cache and serve each other's harvests.
Decision 2 — Class resolution is fail-closed; there is no default class¶
registry.resolve(modalities, scope_type=...) infers the class from the modality strings a landscape already carries. It raises UnsupportedLandscapeClass when the landscape is not modality_target-scoped, when no class matches, or when several match.
Chosen: raise. There is no default_class(), and no klass=None path on engine entry points.
Not chosen:
- Fall back to RNAi. This is the corruption path above, in its most direct form. A discovery run that returns nothing because of a config mismatch must fail loudly, not produce a confidently empty landscape that reads as a real negative result.
- Guess when several classes match. A genuinely mixed-modality thesis is an analyst decision. Guessing silently halves the scope.
Refinement — a designed-but-unregistered class gets a distinct message. _KNOWN_UNREGISTERED recognises antibody and small-molecule vocabulary and says so, because the correct fix there is "write the adapter", not "fix your modalities". These messages are written to be read by an analyst and are propagated verbatim to API callers.
Decision 3 — Over HTTP, the caller declares the class; the endpoint never infers it¶
POST /api/explore/decompose (Inc 3) is the first HTTP surface over the engine. It hits a genuine circular dependency: decompose_core needs a prompt + gene-alias map that only a LandscapeClass supplies, but registry.resolve keys on modalities — which are what decompose produces.
Chosen: the request declares the class, by landscape_class (a registered adapter by name), modalities (inferred), or landscape_id (resolved from a row), checked most-explicit first. None of them is a 422, carrying the registry's message verbatim.
Not chosen:
- A class-neutral pre-pass that detects the class, then runs the class decompose. This requires authoring exactly the one universal prompt that the RNAi and GNS561 runs disproved, and a mis-detection then silently selects the wrong class's prompt — the failure mode Decision 2 exists to prevent, reintroduced one layer up.
- Default to RNAi when unspecified. As Decision 2.
This is aligned with the UX spec, not a concession to it. CLAUDE.md principle #4 says Explore surfaces the scope the LLM inferred as editable chips before any search runs; the class is simply one more such constraint, confirmed up front. GET /api/explore/classes exists so the selector offers only what the engine has an adapter for, rather than a hardcoded list that drifts from the registry.
If a later UX pass wants "just type a thesis", the acceptable shape is a deterministic keyword pre-pass that proposes a class, returned flagged as inferred for confirmation — a proposal the analyst approves, never a silent selection.
Decision 4 — The cross-deck benchmark registry is the merge gate¶
Any change to discovery_modality/ must re-score every deck in archived_data/landscape_loop_benchmarks.md — currently cardiometabolic-rnai-001 and obesity-rnai-001 — by replaying each row's recorded seed + scorer command against a fresh copy DB. Any recall regression on any deck reverts the lever.
This is what makes "class-neutral" a measured claim rather than an aspiration. The refactor's correctness criterion is not "the tests pass" but "the two RNAi decks score exactly what they scored before", because the whole risk of extracting a class seam from a working implementation is a behavioural change that no unit test observes.
Two standing rules attach to it: replays run against a copy DB, never the production ogur.db (the discovery harness and coverage scorers read it), and matcher edits get extra scrutiny because they hit every deck at once.
What may not be claimed yet¶
The registry currently returns ('rnai',). Until a genuine non-RNAi adapter lands and passes the gate:
- Do not describe the engine as "class-parameterized" in customer-facing material. It is class-parameterizable: the seam exists and is exercised by one class. That is a design claim, not a coverage claim.
- An antibody or small-molecule thesis is expected to 422. That is the system working. Do not paper over it to make a demo run.
- The Polygon and GNS561 adaptations remain in their eval-side sibling packages.
/landscape-loop-promoteis the mechanism that decides which of them generalise intodiscovery_modality/; promotion is gated on the same benchmark registry.
Consequences¶
- A new class is a new file plus a registry entry, not an edit to shared code.
classes/rnai.pyis the reference implementation and the shape a second class copies. - The shared matcher stays shared. Only the knobs named on
LandscapeClass(cpc_prefixes,patents_backend,research_org_extra_tokens) are per-class;normalise_*and the CJK layer stay inmatching.py. This preserves the "never fork the matcher" invariant while giving classes a legitimate place to differ. - The engine imports no HTTP and no
scripts/.LandscapeClass.assembleisNoneby default precisely because the ground-truth deck scorer depends on deck CSV fixtures underscripts/; the harness injects its assembler through that field instead. - Legacy byte-identical behaviour is the shims' job. With no
klass=Nonepath, thescripts/entry points build the RNAi context explicitly. Reading a call site now tells you which class it runs. - Error text is a contract. Because registry messages surface unmodified through the API to an analyst, editing one changes user-visible copy and the API tests that assert on it.
Open questions (deferred)¶
- Where does
landscape_identer the projectors? Nine projectors still derive it implicitly. Threading the run context through them is Inc 4+; until then the run context is authoritative only at the stages that already accept it. - Do the two harnesses collapse? The GT and Explore harnesses share most of a pipeline. Merging them is attractive but must not happen before a second class exists, or the merged shape will simply re-encode RNAi's.
- Multi-class theses.
resolvedeliberately raises when several classes match. Whether a mixed thesis should run both classes and union the results, or remain an analyst decision, is unresolved — and cannot be answered with one class registered.