Ran the humanizer rules over 79 prose files in the active specs (brief, design, requirements, research and narrative docs). Removed em dashes, significance inflation, forced rule-of-three, bold-label list items and filler. Skipped tasks.md checklists and the retired specs (harnessx-*, openrouter-sast-harness, unseen-repo-evaluation). Every number, path, command, hash and identifier was preserved: checked each file against HEAD for drift in numbers, inline code and code fences, zero drift across all 79 files. Co-Authored-By: Claude Opus 4.8 <[email protected]>
46 KiB
Design Document: learning-harness
Retired 2026-09-30: HarnessX was removed by
harnessx-removal; the agentic plane is ax (ai-service-plane), and evolutionary proposals come from the plane memory. History below is unchanged.
Overview
Purpose: Turn OpenUltraSAST from a hand-authored analyzer with a leaking improvement loop into a learning harness: an auto-classifier routes every pair, finding and code region to one of ten verifier-aligned families; one detector configuration per family is evolved one change per round from structured failure facts; a class-aware pair-wise scorer with unscorable denominators and a measured per-family noise floor decides acceptance; only verifier-confirmed claims are reported above suspicion; the split is enforced by construction.
Users: the maintainer runs round zero, evolve rounds and the publish command; operators read scan findings that now carry a family, a detector version and a verifier outcome; reviewers work the classifier disagreement queue.
Impact: the tool hunter becomes the primary detector and the static machinery becomes tools and verifiers. The pair scorer's hunter path, the exporter, leave-one-out, the mechanism lever and the improve journal change behavior; findings, catalogs and the manifest gain additive fields. Gates stay byte-identical.
Goals
- One trustworthy number per family per slice, with its denominator, taxonomy version, noise floor and cost.
- A round can only be accepted when it helps its target family on held-out pairs and hurts no other family beyond its measured budget.
- Nothing above
suspicionwithout a verifier; verifier code and gold labels outside every editable surface. - Round zero on the web families over vibe-py and agent-vfc before any learning.
Non-Goals
- Browser-engine, interpreter or renderer bugs; crash oracles for C and C++ (the memory-safety family is measured, never gating).
- New static shape families, new hand-written facts, evidence-ladder or disposition changes, merge gates on non-local slices, model fine-tuning.
- A general router framework or a generic agent platform; the classifier is a function and a detector configuration is a directory.
Boundary Commitments
This Spec Owns
- The family taxonomy file, its loader and its version (1.x).
- Classification of pairs, findings and regions, and the classifier's measurement report and review queue (2.x).
- Class-aware pair scoring: outcomes, directional bias, fixed-FPR view, K-run reliable-change test, negative flips (3.x).
- Unscorable accounting and its reasons; the additive
unscorableandfamilyfields on catalog rows (4.x). - The single teacher-selection rule every learning path calls, and holdout refusal (5.x).
- Per-family detector configurations, their loader, the family detector runner and finding tags (6.1–6.5); the chat-endpoint resolution independent of embeddings (6.6).
- The verifier registry keyed by family, canary oracles, the second-judge step (7.x).
- Round zero, the noise floor, evolve rounds, acceptance, attribution, the archive, the round journal, cost caps (8.x, 9.x).
- The corpus repairs listed in 10.x and the publish command with committed artifacts (11.x).
Out of Boundary
- Pair labels, tiers, splits and
known_limitsemantics beyond the two additive fields (pair-corpus-honesty). - Facts, walker, dominance, obligations checker internals; the mechanism store schema; the rule and policy levers' validators.
- The sandbox runner, snippet safety rules and Docker images; this spec adds an oracle to the verdict, not a runner.
- HarnessX internals; this spec composes
MetaAgent.evolveand the journal behindharness_ext. - Harvesting new pairs; only re-harvesting existing absence recipes.
Allowed Dependencies
pairs,benchmark,semantic.loo,semantic.seed,semantic.mechanisms,improve.evolve,improve.validator,tool_hunter,hunter_tools,provider.openrouter,regress.*,sandbox.*,redaction,harness_ext,config.- Optional extras only through
harness_ext(HarnessX) andsemantic.extra(tree-sitter, now including TypeScript); every step degrades to a recorded reason without them. - Direction:
learning.families→learning.classify→learning.scoring→learning.split→learning.detectorsandlearning.verifiers→learning.rounds→learning.publishandcli.learning.*imports the packages above; nothing outsidelearningimportslearning.roundsorlearning.publish.pairsandbenchmarknever importlearning.
Revalidation Triggers
- A change to the family list, a family's CWE set or its verifier (taxonomy version bump).
- A change to the pair outcome vocabulary or the reliable-change test.
- A change to the acceptance rule, the regression-budget definition or the journal schema.
- A change to the chat-endpoint resolution order or the tool-hunter finding shape.
- A change to the sandbox verdict contract (oracle callback) or snippet safety rules.
Architecture
Existing Architecture Analysis
tool_hunter.run_tool_hunteris a 4-step loop over three curated tools with findings parsed intoStaticFindingatsuspicion(finding_id = tool-hunter:<path>:<line>, tagtool-hunter). It refuses to emit findings when no tool was called. It has no temperature, budget or context knobs beyondmax_steps.pairs._hunter_matches_expectedkeys detection on text tokens and_evaluate_hunter_paircounts every fixed-side finding as a leak underfix_policy = silent; both are replaced on the hunter path only. The overlay and inventory paths andpair_gateare untouched.evaluate_profiles,evaluate_mechanism_profiles,propose_mechanism_edits,evaluate_looandbuild_pair_signalsfilter by tier and vendored status but not by split;export_mechanismsseeds from every case it is given.OpenRouterChatClientandOpenRouterEmbeddingClientread the sameOPENROUTER_BASE_URL;complete_chatalways sendstemperature: 0and dropsreasoning_contentfrom replies. DeepSeek requires thinking disabled for temperature to apply,reasoning_contentreplayed on tool-call turns, no/v1suffix,json_objectonly, no seed.regress.candidatederives a verdict from the exit code; snippets are compile-only templates or the model'sproposed_snippet;check_snippet_safetybans sockets, curl and workspace writes, which a canary file in/scratchrespects.MechanismStoreis an append-only JSONL with tombstones and deterministic ids, the pattern reused for the archive and the journal.- HarnessX 0.1.0 provides
MetaAgent.evolve(current_config, trajectories_dir, output_dir)with post-flight checks,JournalEntrySpec(round, label, hypothesis_id, levers, predicted_affected, prose),build_contextwith precision discounted by unpredicted regressions, andRLTask.task_typerouting.
Architecture Pattern & Boundary Map
graph TB
subgraph Corpus
Catalog[pair catalogs]
Loader[pairs loader with unscorable and family fields]
end
subgraph Learning
Families[families taxonomy]
Classify[classifier three tiers]
Scoring[class aware pair scorer]
Split[teachers and holdout refusal]
Detectors[family detector configs and runner]
Verifiers[verifier registry canary static none]
Rounds[round zero and evolve rounds]
Journal[round journal and archive]
Publish[publish command]
end
subgraph Existing
Hunter[tool hunter loop]
Chat[chat endpoint client]
Regress[regress verdicts and sandbox]
Levers[exporter loo mechanism lever]
Meta[HarnessX MetaAgent optional]
end
Catalog --> Loader --> Scoring
Families --> Classify --> Scoring
Families --> Detectors
Families --> Verifiers
Classify --> Detectors
Split --> Levers
Split --> Rounds
Detectors --> Hunter --> Chat
Verifiers --> Regress
Scoring --> Rounds
Detectors --> Rounds
Verifiers --> Rounds
Rounds --> Journal
Rounds --> Meta
Journal --> Publish
Scoring --> Publish
Architecture Integration:
- Selected pattern: a
learningpackage layered left to right (families → classify → scoring → split → detectors/verifiers → rounds → publish), composing existing seams rather than replacing them. - Domain boundaries: the corpus owns labels and rows;
learningowns class assignment, scoring, teaching rules, detector configurations, verifier keys and rounds;regressowns execution;harness_extowns the HarnessX seam. - Existing patterns preserved: append-only JSONL with tombstones for the archive and journal;
HarnessRuntime.run_stageand degradations for every step;ScriptedChatClientfor offline tests;FakeSandboxRunnerfor verifier tests. - New components rationale: each is one requirement area with one owner; the classifier and scorer are functions, the detector configuration is a directory, the journal is a file.
- Steering compliance: core
dependencies = []; recall first under a false-positive ceiling, now per family; gates byte-identical; no model-authored artifact retained without held-out validation.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| Chat endpoint | DeepSeek platform API, OpenAI-compatible, https://api.deepseek.com (no /v1); models deepseek-v4-flash (default), deepseek-v4-pro (second judge, review queue) |
Detectors, classifier model tier, proposer, judge | Thinking on by default: the client sends thinking: {"type": "disabled"} for detectors and the classifier so temperature applies; reasoning_content replayed on tool-call turns; response_format json_object with empty-content retry; logprobs requested by the classifier tier only. No seed: determinism is best-effort and absorbed by the noise floor. Pricing (peak, USD/1M): flash 0.44 in / 1.32 out, cache hit 0.014; pro 1.32 / 3.96. Concurrency 2500 (flash). |
| Embedding endpoint | OpenRouter, openai/text-embedding-3-small, unchanged |
Prove-order retrieval | Reads OPENROUTER_* only; DeepSeek has no embeddings endpoint. |
| Evolve loop | HarnessX 0.1.0 (meta_harness.agent.MetaAgent, meta_harness.journal), optional extra |
Proposal generation and attribution context | Behind harness_ext.require_harnessx; a scripted proposer stands in without it. |
| Parsing | tree-sitter extra + tree-sitter-typescript>=0.23 |
TypeScript rows become scorable | Optional extra only. |
| Sandbox | existing Docker CLI runner, images python:3.12-alpine, node:22-alpine |
Canary verifiers | Oracle callback added to the verdict; safety rules unchanged. |
| Storage | JSONL under .openultrasast/learning/ and benchmarks/measurements/ |
Journal, archive, noise floors, published artifacts | Append-only, committed when published. |
File Structure Plan
Directory Structure
src/openultrasast/learning/
├── __init__.py # public surface: load_families, classify_*, score_pair_family, teachers, run_baseline, run_learning_round, publish
├── families.py # Family, FamilyTaxonomy, load_families(ruleset/families.toml), version, verifier kind per family
├── classify.py # Classification, classify_pair/finding/region (tiers), measure_classifier, review queue entries
├── scoring.py # FamilyOutcome, score_pair_family, FamilyMetrics, directional_bias, negative_flips, reliable_change, fixed_fpr_view, unscorable_reason
├── split.py # teachers(cases), refuse_if_holdout(candidate_pairs, cases) -> Refusal | None
├── detectors.py # FamilyConfig, load_family_configs, run_family_detector (whole-file context, curated tools), finding tags
├── endpoint.py # ChatEndpoint, resolve_chat_endpoint (DeepSeek first, OpenRouter fallback, scripted), DeepSeekChatClient adapter
├── verifiers.py # Verifier protocol, CanaryVerifier, StaticVerifier, NoVerifier, verifier_for(family), second_judge
├── rounds.py # run_baseline (round zero, noise floor), run_learning_round (propose, minibatch, holdout, sweep, accept/revert), Attribution
├── proposer.py # Proposer protocol, ScriptedProposer, HarnessXProposer (MetaAgent.evolve behind harness_ext), failure facts, rejected buffer
├── journal.py # RoundRecord, LearningJournal (append-only JSONL), Archive (family -> winning config per pair)
└── publish.py # publish(): regenerate numbers from one command, write roadmap table and measurement artifacts
src/openultrasast/ruleset/families.toml # closed, versioned taxonomy: id, cwes, mechanisms, verifier, description
src/openultrasast/learning/configs/<family>/ # round-zero detector configuration per family (prompt.md, checklist.md, tools.toml, budget.toml, hard_negatives.jsonl, counterexamples.jsonl)
tests/test_learning_families.py, test_learning_classify.py, test_learning_scoring.py, test_learning_split.py, test_learning_detectors.py, test_learning_endpoint.py, test_learning_verifiers.py, test_learning_rounds.py, test_learning_journal.py, test_learning_publish.py, test_fixture_difficulty.py
Modified Files
src/openultrasast/pairs.py:PairCase.unscorable: str | Nonecomputed at load (identical twin via normalized body hash,known_limitpassthrough); duplicate report across pairs before split;split_by_repositoryhelper honoring existingsplitfields and reporting straddles;_evaluate_hunter_pairdelegates matching and leaks tolearning.scoringthrough an injected callable sopairsdoes not importlearning;build_pair_signalsgains asplitfilter.src/openultrasast/benchmark.py:ExpectedFinding.family: str | None = None(additive, stored as an opaque string; the Classifier validates loaded labels against the taxonomy sopairsandbenchmarknever importlearning).src/openultrasast/tool_hunter.py:run_tool_huntergainssystem_prompt,context_files,tools,max_charsandtagsparameters with today's values as defaults; finding parsing accepts an optionalfamilyfield; the reply loop replaysreasoning_content.src/openultrasast/hunter_tools.py: four new tools besideread_file,grep_repo,find_refs:read_definition(symbol),entry_points(path),flows(path, function),obligations(path, function), each clamped to the repository and returning identifiers and line spans only;HUNTER_TOOLSschemas extended.src/openultrasast/provider/openrouter.py:OpenRouterChatClientgainsextra_body(thinking, response_format, logprobs) and retains thereasoning_contentfield on assistant messages; replaying it across tool turns is the hunter loop's job (tool_hunter), not the client's. No behavior change for existing callers.src/openultrasast/config.py:[models]gainschat_base_url,chat_api_key_env,judge;[learning]section (families_path,configs_dir,k_runs = 5,round_cost_cap_usd,minibatch,cutoff_date);load_dotenvunchanged, tests inject endpoints explicitly.src/openultrasast/semantic/seed.py,semantic/loo.py,improve/evolve.py: teacher selection throughlearning.split.teachers; refusals recorded;evaluate_looteaches from train only.src/openultrasast/regress/candidate.py,regress/verdict.py:run_regressionaccepts an optionaloracle: Callable[[SandboxResult], bool];verdict_from_resultuses the oracle when given, exit code otherwise.src/openultrasast/cli.py:ousast learning classify|score|baseline|round|publish;pairs --hunteruses the family scorer and--k-runs;[models].hunterresolution throughlearning.endpoint.pyproject.toml:tree-sitter-typescript>=0.23in the semantic extra;src/openultrasast/semantic/extra.pymapstypescript/tsx.benchmarks/pairs/vibe-py/catalog.tomlandrecipes.toml: the five identical twins gainknown_limit = "identical_twin"; absence rows gainfamilyandmode = "handler_context"(re-harvest is a maintainer step).- Cross-file registration context (amended during task 4.1). Some frameworks keep the route table out of the handler module: connexion registers by
operationIdin an OpenAPI document, an Express application registers in a router file. A single-file excerpt of such a handler can never contain its registration, so Req 10.2 is unreachable for those rows without a second file. A recipe may therefore namecontext = ["<repo-relative path>"];benchmarks/pairs/harvest.pyfetches each document at the same two commits, extracts only the stanza that names the handler (extract_registration_context), and writes it under<name>/context/<side>/<relpath>with its own provenance header and the same redaction. The catalog carries[[pair.context]]rows,PairCase.context_filesholds(relpath, vuln, fixed), and_materialize_sidecopies them beside the excerpt at their repository-relative paths so the entry-point mapper sees the same layout the project has.src/openultrasast/mapping.pygains_route_manifest_entry_points, which readsoperationIdand the operation'ssecurityblock out of a YAML or JSON document and attributes the record to the module the operation id points at. Trap pairs whose fixed side is a different, correctly guarded handler carryfix_functionso the fixed side gets its registration rather than the vulnerable side's. benchmarks/pairs/mechanisms.toml: unchanged; families reference mechanism ids..kiro/steering/roadmap.md: rewritten bypublishfrom artifacts.
System Flows
Class-aware scoring of one pair (hunter path)
sequenceDiagram
participant S as Scorer
participant C as Classifier
participant D as FamilyDetectors
participant V as Verifier
S->>C: classify_pair(case)
C-->>S: families or unknown, tier
loop K runs
S->>D: run on vuln side and fixed side
D-->>S: findings with family tags
end
S->>S: detection = family match inside labeled function
S->>S: silence = no labeled family inside function on fixed side
S->>V: verify claims that a verifier exists for
V-->>S: suspicion, static corroboration, or proven
S-->>S: FamilyOutcome, negative flips, reliable change
Decisions: unscorable pairs short-circuit before any run; K runs share one classification; verifier outcomes never change detection or silence, they change the reported rung.
One evolve round
flowchart TB
Start[failure facts for target family] --> Propose[proposer drafts one structural change]
Propose --> Refuse{touches holdout or exceeds length cap}
Refuse -->|yes| Reject[reject and record]
Refuse -->|no| Mini[train minibatch of target family]
Mini --> MiniOk{no decrease}
MiniOk -->|no| Reject
MiniOk -->|yes| Hold[target family full holdout]
Hold --> HoldOk{train and holdout non negative and one strictly positive}
HoldOk -->|no| Reject
HoldOk -->|yes| Sweep[every other family holdout]
Sweep --> SweepOk{negative flips within each budget}
SweepOk -->|no| Reject
SweepOk -->|yes| Accept[accept freeze config bump version]
Accept --> Journal[journal hypothesis predicted attribution cost]
Reject --> Journal
Journal --> Buffer[rejected edit buffer updated]
Decisions: the proposer never sees holdout inputs, outputs or scores; cost is metered per stage and the round is reverted when the cap is crossed at any stage; ties reject.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1–1.4 | closed taxonomy, verifier per family, version, attributes not keys | Families | load_families, FamilyTaxonomy |
— |
| 2.1–2.5 | three-tier multi-label classifier, measured, review queue, whole corpus | Classifier | classify_*, measure_classifier |
scoring |
| 3.1–3.6 | family-in-function detection and silence, outcomes, bias, FPR, K runs, no text tokens | Scorer | score_pair_family, FamilyMetrics |
scoring |
| 4.1–4.5 | unscorable reasons, never delete, dedup, repository split, denominators shown | Loader (pairs), Scorer, Publish | PairCase.unscorable, unscorable_reason, split_by_repository |
— |
| 5.1–5.4 | train-only teachers, refusal, re-measure lever, proposer blind | Split, Levers, Proposer | teachers, refuse_if_holdout |
round |
| 6.1–6.6 | per-family config, frozen, routing plus generalist, whole file, curated tools, independent chat endpoint | Detectors, Endpoint | FamilyConfig, run_family_detector, resolve_chat_endpoint |
scoring |
| 7.1–7.6 | suspicion unless verified, canary oracles, static corroboration, none, second judge, sealed surface | Verifiers, Regress | Verifier, verifier_for, second_judge, run_regression(oracle=) |
scoring |
| 8.1–8.4 | clone, K = 5 noise floor, publish round zero, web families first | Rounds | run_baseline, NoiseFloor |
— |
| 9.1–9.9 | failure facts, one change, journal, staged evaluation, acceptance, attribution, archive, cost cap, retention | Rounds, Proposer, Journal | run_learning_round, Proposer, RoundRecord, Archive |
round |
| 10.1–10.5 | TypeScript, re-harvest, identical twins, fixture difficulty, post-cutoff slice | Extra, Corpus, Tests | semantic.extra, catalog fields, test_fixture_difficulty |
— |
| 11.1–11.5 | one command, artifacts committed, trajectories persisted, degrade with reason, gates identical | Publish, Journal, Runtime | publish, LearningJournal |
— |
Components and Interfaces
| Component | Layer | Intent | Req | Key Dependencies | Contracts |
|---|---|---|---|---|---|
| Families | data | closed taxonomy with verifier per family | 1.x | ruleset file (P0) | Service |
| Classifier | logic | assign families with tier, measure itself | 2.x | Families (P0), Endpoint (P1) | Service, State |
| Scorer | logic | class-aware pair outcomes and statistics | 3.x, 4.x | Families (P0); findings from Detectors and rungs from Verifiers are inputs, not imports (P1) | Service |
| Split | logic | the one teacher rule and holdout refusal | 5.x | pairs (P0) | Service |
| Detectors | logic | per-family configuration and runner | 6.1–6.5 | tool_hunter (P0), Endpoint (P0), Classifier (P0) | Service, State |
| Endpoint | integration | independent chat endpoint resolution and DeepSeek adapter | 6.6 | provider.openrouter (P0), config (P0) | Service |
| Verifiers | integration | family-keyed oracles and the second judge | 7.x | regress, sandbox (P0), obligations (P1) | Service |
| Rounds | orchestration | round zero, noise floor, evolve rounds, acceptance | 8.x, 9.x | Scorer, Detectors, Proposer, Journal (P0) | Batch, State |
| Proposer | integration | structural change proposals from failure facts | 9.1–9.2 | harness_ext (P1), Endpoint (P1) | Service |
| Journal | data | round records, attribution, archive | 9.3, 9.6, 9.7, 11.3 | JSONL (P0) | State |
| Publish | orchestration | numbers from one command, artifacts, roadmap | 11.x | Scorer, Journal (P0) | Batch |
Data layer
Families
| Field | Detail |
|---|---|
| Intent | Load and version the closed family taxonomy with its CWE set, mechanism set and verifier kind |
| Requirements | 1.1, 1.2, 1.3, 1.4 |
Responsibilities & Constraints
- Exactly ten families as decided; ids are stable identifiers; the file carries
version. - Verifier kind per family is one of
canary,static,none; memory safety isnone. - No component may route or score on a CWE or mechanism id; they are looked up only through a family.
Dependencies: Inbound: Classifier, Scorer, Detectors, Verifiers (P0). External: ruleset/families.toml (P0).
Contracts: Service [x]
@dataclass(frozen=True)
class Family:
id: str # e.g. "injection", "path", "deserialization", "access_control", "output_encoding", "untrusted_destination", "prototype", "config_secrets", "memory", "unknown"
cwes: frozenset[str]
mechanisms: frozenset[str]
verifier: Literal["canary", "static", "none"]
description: str
@dataclass(frozen=True)
class FamilyTaxonomy:
version: str
families: tuple[Family, ...]
def by_id(self, family_id: str) -> Family: ...
def family_of_cwe(self, cwe: str) -> Family | None: ...
def family_of_mechanism(self, mechanism: str) -> Family | None: ...
def related(self, a: str, b: str) -> Literal["same", "parent_child", "lateral", "fabricated"]: ...
def load_families(path: Path | None = None) -> FamilyTaxonomy # raises FamiliesError naming the field on any deviation
- Invariant:
unknownis always present and never has a verifier.
Journal
| Field | Detail |
|---|---|
| Intent | Append-only record of rounds with attribution, and the archive of which configuration wins which pair |
| Requirements | 9.3, 9.6, 9.7, 11.3 |
Contracts: State [x]
@dataclass(frozen=True)
class RoundRecord:
round: int
family: str
hypothesis: str
levers: tuple[str, ...] # "tool" | "checklist" | "counterexample" | "memory" | "prompt"
predicted_affected: tuple[str, ...]
predicted_at_risk: tuple[str, ...]
outcome: Literal["accepted", "rejected", "reverted_cost", "refused_holdout"]
reason: str
target_train_delta: float
target_holdout_delta: float
sweep: dict[str, int] # family -> negative flips
attribution: Attribution # flipped_predicted, flipped_unpredicted, precision
cost_usd: float
taxonomy_version: str
config_version: str
class LearningJournal: # .openultrasast/learning/journal.jsonl
def append(self, record: RoundRecord) -> None: ...
def rounds(self) -> tuple[RoundRecord, ...]: ...
def rejected_buffer(self, family: str) -> tuple[str, ...]: ... # hypotheses rejected for this family
class Archive: # .openultrasast/learning/archive.jsonl
def record(self, family: str, config_version: str, pair: str, outcome: FamilyOutcome) -> None: ...
def winners(self, family: str) -> dict[str, str]: ... # pair -> config version that scores it best
- Persistence: JSONL, one record per line, never rewritten; the HarnessX journal is mirrored when the extra is present, never the source of truth.
Logic layer
Classifier
| Field | Detail |
|---|---|
| Intent | Assign zero or more families with the deciding tier; measure itself; queue disagreements |
| Requirements | 2.1, 2.2, 2.3, 2.4, 2.5 |
Responsibilities & Constraints
- Tier 1 (deterministic): rule id, sink family from facts, obligation kind, mechanism id, labeled CWE; any hit decides without a model.
- Tier 2 (model):
json_objectquestion with the taxonomy definitions,thinkingdisabled,logprobsrequested; averaged log-probability under a threshold yieldsunknown. - Tier 3:
unknown. - Never writes a label; disagreements go to
.openultrasast/learning/review-queue.jsonl. - Validates every loaded catalog
familylabel against the taxonomy at classification time and rejects a fabricated id by name; the corpus loader stores the label as an opaque string.
Dependencies: Inbound: Scorer, Detectors (P0). Outbound: Families (P0), Endpoint (P1, degrades to tier 1 + unknown without a client).
Contracts: Service [x] State [x]
@dataclass(frozen=True)
class Classification:
families: tuple[str, ...] # () means unknown
tier: Literal["static", "model", "none"]
confidence: float | None
def classify_pair(case: PairCase, taxonomy: FamilyTaxonomy, *, client: ChatClient | None) -> Classification
def classify_finding(finding: StaticFinding, taxonomy: FamilyTaxonomy, *, client: ChatClient | None) -> Classification
def classify_region(path: str, text: str, function: str | None, taxonomy: FamilyTaxonomy, *, facts, client: ChatClient | None) -> Classification
@dataclass(frozen=True)
class ClassifierReport:
taxonomy_version: str
agreement: float # hierarchical credit vs maintainer labels on reviewed pairs
confusion: dict[str, dict[str, int]]
random_router: float
oracle_router: float
per_tier: dict[str, int]
unknown: int
queued: int
def measure_classifier(cases: Sequence[PairCase], taxonomy: FamilyTaxonomy, *, client: ChatClient | None) -> ClassifierReport
- Postcondition: every reviewed pair whose label disagrees with the classification appears once in the review queue with both answers and the tier.
Scorer
| Field | Detail |
|---|---|
| Intent | Score one pair per family from detector findings, aggregate per family and slice, decide reliable change |
| Requirements | 3.1–3.6, 4.1, 4.5 |
Responsibilities & Constraints
- Detection: a finding whose
family:tag relatessameorparent_childto the labeled family and whose line lies in the labeled function;fabricatedscores the maximum penalty; text is never inspected. - Silence: no
same/parent_childfinding of the labeled family inside the labeled function on the fixed side; other findings are counted inother_findings, never as leaks. - K runs per pair at every evaluation (baseline K = 5, rounds K ≥ 3 as 3.5 requires); reliable change against the last blessed round via a paired test on per-pair outcomes; negative flips per family.
Contracts: Service [x]
FamilyOutcome = Literal["pair_correct", "both_flagged", "both_silent", "reversed", "unscorable"]
@dataclass(frozen=True)
class PairFamilyScore:
pair: str
family: str
runs: tuple[FamilyOutcome, ...] # length K
outcome: FamilyOutcome # majority of runs
other_findings_vuln: int
other_findings_fixed: int
rung: Literal["suspicion", "static_corroboration", "proven"]
unscorable_reason: str | None
@dataclass(frozen=True)
class FamilyMetrics:
family: str
scorable: int
unscorable: dict[str, int] # reason -> count
outcomes: dict[FamilyOutcome, int]
recall: float
silence: float
youden: float
directional_bias: float # (both_flagged - both_silent) / scorable
fixed_fpr_recall: float # recall at the family's FPR ceiling
negative_flips: int # vs last blessed round
reliable_change: bool
taxonomy_version: str
def score_pair_family(case: PairCase, family: str, vuln_runs: Sequence[Sequence[StaticFinding]], fixed_runs: Sequence[Sequence[StaticFinding]], *, ranges, taxonomy) -> PairFamilyScore
def aggregate(scores: Sequence[PairFamilyScore], *, baseline: Sequence[PairFamilyScore] | None) -> dict[str, FamilyMetrics]
def unscorable_reason(case: PairCase, *, parse_ok: bool, ranges) -> str | None # identical_twin | unsupported_language | unresolved_label | missing_context | known_limit:<x>
Split
| Field | Detail |
|---|---|
| Intent | The one rule that selects teachers, and the refusal for anything touching holdout |
| Requirements | 5.1, 5.2, 5.3, 5.4 |
Contracts: Service [x]
def teachers(cases: Sequence[PairCase]) -> tuple[PairCase, ...] # vendored, gating tier, split == "train", scorable
def refuse_if_holdout(taught_by: Iterable[str], cases: Sequence[PairCase]) -> Refusal | None # Refusal(pairs=..., reason="holdout_pair")
export_mechanisms,evaluate_loo,evaluate_profiles,evaluate_mechanism_profiles,propose_mechanism_editsandbuild_pair_signalsobtain teachers only throughteachers; refusals are recorded as degradations{"stage": "learning", "reason": "holdout_pair_refused", "pairs": [...]}.
Detectors
| Field | Detail |
|---|---|
| Intent | Load frozen per-family configurations and run the family detector with whole-file context and curated tools |
| Requirements | 6.1, 6.2, 6.3, 6.4, 6.5 |
Responsibilities & Constraints
- A configuration is a directory
learning/configs/<family>/withprompt.md(length cap enforced at load),checklist.md,tools.toml(subset ofread_file,grep_repo,find_refs,read_definition,entry_points,flows,obligations),budget.toml(max_steps,max_cost_usd,max_chars),hard_negatives.jsonl,counterexamples.jsonl,version. - Round zero writes every family's directory from the current hunter prompt; a round rewrites only its target family and bumps that
version. - The runner builds the user message from the whole file plus resolved definitions, runs
run_tool_hunterwith the family's tools and prompt, and tags findingsfamily:<id>,detector:<id>@<version>; the generalist is theunknownfamily's configuration.
Contracts: Service [x] State [x]
@dataclass(frozen=True)
class FamilyConfig:
family: str
version: str
prompt: str
checklist: str
tools: tuple[str, ...]
max_steps: int
max_cost_usd: float
max_chars: int
hard_negatives: tuple[dict[str, object], ...]
counterexamples: tuple[dict[str, object], ...]
def load_family_configs(configs_dir: Path, taxonomy: FamilyTaxonomy) -> dict[str, FamilyConfig] # raises ConfigError on cap or tool violations
def run_family_detector(root: Path, region: Region, config: FamilyConfig, *, client: ChatClient, model: str) -> list[StaticFinding]
- Precondition:
region.familiesfrom the classifier; postcondition: every finding carries exactly onefamily:tag and onedetector:tag.
Endpoint
| Field | Detail |
|---|---|
| Intent | Resolve the chat endpoint independently of the embedding endpoint and adapt DeepSeek quirks |
| Requirements | 6.6 |
Responsibilities & Constraints
- Resolution order: explicit injection (tests) →
OPENULTRASAST_HUNTER_CLIENTscripted modes →DEEPSEEK_API_KEYwithDEEPSEEK_BASE_URL(defaulthttps://api.deepseek.com) →OPENROUTER_API_KEYwithOPENROUTER_BASE_URL. Embeddings keep readingOPENROUTER_*only. [models]:hunter(defaultdeepseek-v4.1-flash),judge(defaultdeepseek-v4.1-flash),chat_base_url,chat_api_key_env. V4 Pro was discontinued on 2026-09-10 and its requests route to V4.1 Flash from 2026-09-14, billed at Flash's price. Detector and judge are now the SAME model, so any claim that the judge is a stronger second opinion is a claim about the prompt and the evidence put to it, not about model class.- Adapter:
thinkingdisabled andtemperature 0for detectors and classifier;reasoning_contentreplayed;response_format json_objectwith one retry on empty content;logprobson request; usage fields normalized (prompt_cache_hit_tokens) into cost accounting.
Contracts: Service [x]
@dataclass(frozen=True)
class ChatEndpoint:
base_url: str
api_key: str
provider: Literal["deepseek", "openrouter", "scripted"]
thinking: bool
price_in_per_m: float
price_out_per_m: float
def resolve_chat_endpoint(config: ResolvedConfig, *, override: ChatClient | None = None) -> tuple[ChatClient, ChatEndpoint] | None
class DeepSeekChatClient: # implements ChatClient; wraps OpenRouterChatClient with extra_body and reasoning_content replay
def complete(self, *, model, messages, tools, timeout_seconds=60, json_object=False, logprobs=False) -> ChatResponse
Verifiers
| Field | Detail |
|---|---|
| Intent | Confirm a claim through the family's oracle, or refuse to raise it |
| Requirements | 7.1–7.6 |
Responsibilities & Constraints
CanaryVerifier(injection, path, deserialization, and command execution inside injection): writes a snippet from a family template that imports the target function, plants a canary (row in an in-memory database, file under/scratch, object with a marker method) and printsCANARY:<token>only when the vulnerability fires; the oracle is the token in stdout, never the exit code. The snippet passescheck_snippet_safetyunchanged.StaticVerifier(access control): the obligations checker's finding on the same function raises tostatic_corroboration.NoVerifier: stays atsuspicion.second_judge: adeepseek-v4.1-flashcall with the snippet, the oracle output and the claim, answeringjson_object{"confirms": bool, "reason": str}; a proven claim is published only when it confirms.- Templates, oracle code and gold labels live under
learning/verifiers/andbenchmarks/, both excluded from every proposer write root.
Contracts: Service [x]
class Verifier(Protocol):
kind: Literal["canary", "static", "none"]
def verify(self, claim: StaticFinding, *, root: Path, language: str, runner: SandboxRunner, limits: SandboxConfig) -> Verification
@dataclass(frozen=True)
class Verification:
rung: Literal["suspicion", "static_corroboration", "proven"]
oracle_output: str
reason: str
def verifier_for(family: Family) -> Verifier
def second_judge(verification: Verification, claim: StaticFinding, *, client: ChatClient, model: str) -> bool
regress.run_regression(..., oracle=Callable[[SandboxResult], bool] | None): when given,TRIGGERABLEiff the oracle returns true; exit-code behavior unchanged otherwise.
Orchestration layer
Rounds
| Field | Detail |
|---|---|
| Intent | Round zero with noise floors; evolve rounds with staged evaluation, acceptance, attribution and revert |
| Requirements | 8.1–8.4, 9.1–9.9 |
Responsibilities & Constraints
run_baseline: clone the current hunter prompt into every family directory, run K = 5 over scorable vendored pairs of the selected slices, compute per-family negative-flip rate between runs asNoiseFloor, persistnoise-floors.json, publish round zero. Artifacts and floors are keyed by detector model (baseline/<model>/), so a second model is baselined by changing[models].hunterand re-running;publishrenders a model comparison table from every baseline present. This is the maintainer's reconsideration path if the default model underperforms; configurations are never touched by a model swap.run_learning_round: failure facts for the target family → proposer → refusal checks (holdout, length cap, allowed levers) → minibatch → holdout → sweep → accept (freeze, bump version, archive) or revert (restore the directory snapshot) → journal → rejected buffer.- Acceptance:
train_delta >= 0 and holdout_delta >= 0 and (train_delta > 0 or holdout_delta > 0) and all(sweep[f] <= floor[f]); ties reject. - Cost metered per stage from usage; crossing
round_cost_cap_usdat any stage reverts and recordsreverted_cost. - The proposer receives no holdout material; scoring of holdout happens after the proposal is frozen.
Contracts: Batch [x] State [x]
@dataclass(frozen=True)
class NoiseFloor:
family: str
k_runs: int
negative_flip_rate: float
budget_flips: int # ceil(rate * scorable pairs)
def run_baseline(cases, *, slices, taxonomy, configs_dir, client, model, k_runs=5, out_dir) -> BaselineReport
def run_learning_round(cases, *, family, taxonomy, configs_dir, proposer, client, model, journal, archive, floors, cost_cap_usd, minibatch) -> RoundRecord
- Idempotency: a round directory is written under
.openultrasast/learning/rounds/<n>/with the snapshot, proposal, scores and trajectories; rerunning a round number refuses. - Every stage scores with K runs as the scorer requires; the noise floor is the tolerance for the cross-family sweep, not a substitute for repeated runs.
Proposer
| Field | Detail |
|---|---|
| Intent | Draft one structural change for one family from structured failure facts |
| Requirements | 9.1, 9.2, 5.4 |
Contracts: Service [x]
@dataclass(frozen=True)
class FailureFacts:
family: str
misses: tuple[MissFact, ...] # pair, function, sink_or_check, line, verifier_outcome, runs
leaks: tuple[LeakFact, ...]
rejected: tuple[str, ...] # hypotheses from the buffer
config: FamilyConfig
@dataclass(frozen=True)
class Proposal:
hypothesis: str
lever: Literal["tool", "checklist", "counterexample", "memory", "prompt"]
change: dict[str, object] # file-level edit inside the family directory
predicted_affected: tuple[str, ...]
predicted_at_risk: tuple[str, ...]
class Proposer(Protocol):
def propose(self, facts: FailureFacts) -> Proposal | None
class ScriptedProposer: ... # tests and offline runs
class HarnessXProposer: ... # MetaAgent.evolve with allowed_write_roots = the family directory only; degrades to None with a recorded reason without the extra
Publish
| Field | Detail |
|---|---|
| Intent | Regenerate every published number from one command and commit its artifacts |
| Requirements | 11.1, 11.2, 4.5 |
Contracts: Batch [x]
def publish(cases, *, taxonomy, journal, floors, out_dir=Path("benchmarks/measurements"), roadmap=Path(".kiro/steering/roadmap.md")) -> PublishReport
- Writes
<date>-<slice>-<family>.jsonartifacts, a markdown table with scorable/unscorable counts, taxonomy version, noise floor and cost per point beside every number, and replaces the roadmap's measured section between markers.
Data Models
Domain Model
- Aggregates:
FamilyTaxonomy(versioned, immutable per version);FamilyConfig(per family, versioned, frozen between rounds);RoundRecord(append-only);PairCase(owned by the corpus, gains two additive fields). - Invariants: a finding carries exactly one
family:tag; a pair has at most oneunscorablereason; a round targets exactly one family; the archive maps a pair to one winning configuration per family.
Data Contracts
- Catalog row additions:
[[pair.expected]] family = "<id>"(opaque at load, validated by the Classifier),known_limit = "identical_twin"for the five twins,fix_date = "YYYY-MM-DD"populated by the catalog generator from recipe metadata where available (rows without it are counted as undated in the post-cutoff slice);unscorableis computed, never stored. - Manifest block
learning = {taxonomy_version, families: {id: FamilyMetrics}, classifier: ClassifierReport, round: n | null}; findings carry tagsfamily:,detector:,verifier:<rung>. - Journal and archive JSONL as above;
noise-floors.json;review-queue.jsonlentries{pair, label_family, classified, tier, confidence}.
Error Handling
- Taxonomy or configuration deviations fail fast at load with the field named (
FamiliesError,ConfigError). - Missing extras degrade with a recorded reason: no HarnessX →
ScriptedProposerorproposer_unavailable; no TypeScript grammar → rowsunsupported_language; no sandbox →NoVerifierfor canary families withsandbox_unavailable. - Endpoint errors: transient retries as today; empty
json_objectcontent retried once then recorded; a round whose endpoint fails mid-stage is reverted withendpoint_failed. - Holdout refusal, cost overrun and safety rejection are outcomes, not exceptions, and always land in the journal.
- Monitoring: every stage runs under
HarnessRuntime.run_stage; cost per stage and per round is written to the round directory.
Testing Strategy
- Unit: taxonomy loader rejects an eleventh family and a missing verifier kind (1.1, 1.2);
relatedreturnsparent_childfor a CWE child andfabricatedfor an unknown id (3.1); classifier tier 1 decides from a rule id without a client and tier 2 falls tounknownunder the threshold with a scripted client (2.1, 2.2); scorer counts detection only inside the labeled function and never from text (3.1, 3.6); silence ignores other-family findings (3.2); directional bias and negative flips on synthetic run sets (3.3, 3.5);unscorable_reasonon an identical twin and an unsupported language (4.1);teachersexcludes holdout andrefuse_if_holdoutnames the pair (5.1, 5.2); configuration loader rejects a prompt over the cap and a tool outside the allowed set (6.1, 6.5); endpoint resolution prefers DeepSeek over OpenRouter and never touches the embedding variables (6.6); canary snippet passes safety and the oracle keys on the token (7.2); acceptance rule rejects ties and budget breaches (9.5); journal round-trips a record and the rejected buffer (9.3, 9.6). - Integration:
pairs --hunterwith a scripted client on the shared handler fixture yields family outcomes and the manifest block (3.x, 6.3);export_mechanismsandevaluate_looteach from train only and record refusals (5.1, 5.3);run_baselinewith a scripted client writes noise floors and round-zero artifacts (8.x);run_learning_roundwithScriptedProposeraccepts a helpful change, rejects a tie, reverts on cost and journals attribution (9.x);run_regression(oracle=)returnsTRIGGERABLEon the token withFakeSandboxRunner(7.2). - Corpus: TypeScript rows parse under the extra (10.1); the five twins load as
known_limit:identical_twinand are excluded from denominators (10.3, 4.1);test_fixture_difficultyfails on a fixture easier than its named rows (10.4); post-cutoff slice reported separately (10.5). - Gates:
gate,map_gate,pair_gatestdout byte-identical to the pre-feature commit; extra-free suite green with semantic and harnessx tests skipping (11.5). - Live (maintainer, not CI): round zero on vibe-py and agent-vfc web families through DeepSeek, artifacts committed under
benchmarks/measurements/.
Security Considerations
- Every prompt and every persisted trajectory passes
redact_secrets; verifier templates, oracle code and gold labels are outside the proposer's write roots; snippets keep today's safety bans; the sandbox stays network-off and workspace read-only; the second judge sees the snippet and oracle output, never the labels.
Performance & Cost
- Round zero: K = 5 over ~250 vendored pairs, both sides, whole-file context ≈ 25M input tokens; at DeepSeek flash peak pricing under $15, off-peak half. A round: minibatch + holdout + sweep ≈ one baseline pass;
round_cost_cap_usddefault 10. Concurrency bounded by the runtime's existing budget controls.
Migration Strategy
- Phase 1 (offline, no model): taxonomy, classifier tier 1, scorer, split, unscorable rows, endpoint resolution, TypeScript grammar, identical twins, fixture-difficulty check; existing numbers re-published with denominators.
- Phase 2 (live): round zero on web families; noise floors; classifier tier 2 measured; review queue worked.
- Phase 3: verifiers for the four canary families and the static verifier; second judge.
- Phase 4: evolve rounds with
ScriptedProposerfirst, thenHarnessXProposer; obligations lever as the access-control family's first round. - Rollback: every round is a directory snapshot;
publishregenerates from artifacts, so a bad round is reverted by restoring the family directory and re-publishing.