6.2: the 724 static examples' 616 distinct excerpts embedded with openai/text-embedding-3-small via OpenRouter, 198,819 tokens, $0.0040 metered (ceiling $1). 6.6, family injection only, memory = every family (at most 2 contrast examples per prompt), input profile v1: smoke fixed k = 5 (repeat prefix-cache hit share 0.96); one compile (program c1237a49, proposed instruction 5, balanced Brier 0.128 on 13 C_val candidates vs 0.199 baseline); outer grouped 5-fold, 110 out-of-repository candidates in 29 groups: ADVISORY recall 0.90 [0.82, 0.97], precision 0.54 [0.44, 0.65], AUC 0.74; BLOCK not offered (ECE 0.064 > 0.05; no fold reached a Wilson lower bound of 0.95); canary re-ask agreement 0.83. $0.91 metered at list price; DeepSeek balance $18.20 -> $17.90. Instrument fixes found on the way, each with a test: - memory build: pair sides join the record stored under their harvest unit pin (--units), and only the built profile's records are read (static and plane shared a key; 85 -> 724 examples); - retrieval: stage 1 per side (family / contrast) and at most 2 contrast examples, never padded; - program: the JSON answer contract is part of the signature, appended to proposed instructions (DeepSeek JSON mode refused a prompt without "json"); - compile: candidate_families for a one-family compile; compile.toml k = 5 from the smoke rule. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
102 KiB
Design Document
Overview
Detection decisions move out of hand edits and into an AI classifier with local memory. The instruments stay and
emit signals. A model-free feature builder turns those signals into one record per candidate. A label builder
attaches ground truth from sources we can defend, and labelled cases become a local memory of examples. A
language-model program, compiled DSPy-style in-house (Req 3, changed 2026-10-01: "We will not be able to scale up
data"), reads the candidate's code, its signals and its inferred roles together with similar labelled cases
retrieved from memory, and answers a verdict with a confidence. That confidence is calibrated only on repositories
the program never saw, and two thresholds on it (BLOCK, ADVISORY) are what pre-push, scan and plane Runs report.
No statistical model is fitted. Pipeline changes, program variants included, are pre-registered, paired
experiments.
Six facts from the repository and the recorded results shape the design:
- The deployment target is a repository nobody trained on. Maintainer: "the tool will never run against the
test cases it's being trained with. it's purpose is a repo safety net." (Req 7). The primary unit is a candidate
in changed code (
ousast pre-push, base..head), and whole-repositoryscanis the secondary mode. Every reported number therefore comes from folds grouped by repository across all corpora. Features are repository-agnostic by an allow-list. Fit to a user's own repository is an opt-in layer outside the reported numbers. - Labels are scarce, and full plane signals exist on 43 rows. The only recorded plane run with
verify/agree/tie-break is
validation-46: 15 cases, 43 candidates after triage, 19 of them withsite_matchtrue, all from population v2 (~/ousast-results/plane/validation-46/*-final/agreed.json). The pair corpus is larger (427 pairs and 452 labelled findings in the catalog today, 317/342 in the 2026-09-30 baselinebenchmarks/measurements/2026-09-30-harnessx-removal-baseline/pairs.jsonoverall). Measured by repository rather than by row, it is 146 groups, and two families are nearly single-repository (section 3). A classifier over verify signals cannot be evaluated today. The design starts from the signals every labelled row can have, harvests the rest at a stated cost, and accepts that data will stay small: the engine learns by memory and compilation, not by fitting parameters. - The core install is PyYAML only (
pyproject.toml:11). numpy, scipy and scikit-learn are not installed in.venv. Retrieval (a Gower distance and a cosine over at most 40 vectors) and calibration (two-parameter Platt) stay pure Python; the model calls go through the plane's metered client (section 4). pre-pushalready has the decision seam.admit_candidates(push/policy.py:879) applies one evidence bar toCandidateDeltas withnovelty(push/policy.py:61-74). Its capability registry is empty by design (push/policy.py:802-823: "No shipped declaration is enabled"), sopre-pushblocks nothing today. The engine's operating point replaces the capability-eligibility reasons, and the structural reasons stay.- The memory store is the system of record and has the needed shape. Rows are keyed by repository and pin,
with a closed
KINDStuple (plane/memory.py:52). There are two backends (FileStore, MinioStore) behind one interface (plane/memory.py:176-291), content-addressed blobs (put_facts,plane/memory.py:249-255), and a train-on-testGuard(improve/memory.py:95-133). Features, labels, decisions, model artifacts and experiment outcomes become new row kinds and blob prefixes. No second store is added. - Hand-written framework knowledge is embedded in the detectors (Req 8). Examples are WordPress
$wpdb, hooks andmaybe_unserializeinruleset/semantic/php.tomlandruleset/php/rules.toml, and Flask and Django names inruleset/semantic/python.toml. Section 2 tags each entry. Priors become optional features that are off by default. Framework roles are inferred per repository instead.
Decisions in one place:
| Question | Decision |
|---|---|
| Candidate key | (repo, pin, path, function, family) in the row envelope only; pin is the head for a delta |
| Features | allow-listed, typed, repository-agnostic; explicit null + instrument state for missing; two profiles (static, plane) |
| Where features are built | plane task features (no model) after agree/final and alerts; host path learn.features.build_for_scan for scan/pre-push |
| Labels | host-only ousast learn labels; site/fix-range matcher for populations, vulnerable/fixed side for pairs, benign deltas, adjudications; fail-closed source allow-list keeps v3 out |
| Engine | a language-model program, in-house DSPy-style: signature (code excerpt, signals, roles, family -> verdict, family, confidence, rationale with cited lines), modules Retrieve -> Classify(k), every call through MeteredClient bound to a Model per task; no statistical model is fitted |
| Models | DeepSeek (deepseek-flash) for classification; OpenRouter (openai/text-embedding-3-small) for embeddings only |
| Memory | example rows (label + record + excerpt) in the MemoryStore; stage 1 Gower nearest neighbours on the signal profile with explicit missing, stage 2 code-embedding re-rank, 6 examples under a label/family/repository balance rule; an evaluation-boundary filter with a test |
| Compilation | pre-registered balanced Brier score on a compile split of repositories never evaluated; bootstrap few-shot (3 demonstrations) and instruction search (6 proposed + baseline); ceiling $3 per compile; responses cached for byte-identical replay |
| Families | one program for all families; calibration and operating points per family at >= 20 positive and >= 20 negative repositories, pooled at >= 10, otherwise "insufficient data" |
| Calibration | Platt scaling of the program's score, cross-fitted over outer folds; checked per fold (reliability, slope, intercept, ECE); if it fails, ADVISORY only |
| Folds | outer grouped 5-fold by repository (one repetition), plus leave-one-source-out and leave-one-framework-out; compile split disjoint from every evaluation fold |
| Operating points | BLOCK: lowest threshold whose Wilson lower bound on precision is >= 0.95, chosen on the other folds' predictions, never on a majority unsure; ADVISORY: threshold reaching 0.90 recall; reported with repository-bootstrap intervals |
| Artifacts | content-addressed compiled-program JSON under programs/ in the store; adopted version exported into the package by a maintainer commit |
| Cost | ~$0.003 per candidate (k = 1), ~$0.0045 (k = 5, cached); ~$2.2 per compile; ~$3.4-5.2 per full evaluation; first slice ~$5, $7 ceiling, within today's balance |
| Experiments | manifest plane/experiments/<id>.yaml + Run annotation openultrasast.io/experiment; paired; repository-cluster bootstrap CI (primary), exact McNemar (secondary); O'Brien-Fleming two-look stopping |
| First experiment | exp-001-known-callers: callers line on vs off, development corpus (v1, pmpro, MediaWiki, WP Statistics, full-repository pointer pairs), about $8 expected, $15 ceiling |
| Framework knowledge | tagged framework/library priors, off by default; roles inferred by a roles plane task (model) + deterministic wrapper inference; leave-one-framework-out evaluation |
Architecture
instruments (signal producers, unchanged as standalone tools)
quick rules Joern engine repo-facts roles (new) verify a/b/c + agree
alerts task engine_alerts / callers, entry model role labels (plane only, Model-bound)
pre-push scan distance + wrapper inference
\ | | | /
+--------------+------------------+-------------------+--------------------+
|
features (new, no model): allow-listed record per candidate
plane: task after final + alerts host: learn.features.build_for_scan
|
+---------------------+----------------------+
| |
labels (new, host only, never in a sandbox) embed (new, Model: OpenRouter embeddings)
populations: matches_v2 / fix ranges |
pairs: vulnerable vs fixed side decide (new, Model: DeepSeek, metered)
benign deltas, adjudications Retrieve (signals -> embeddings, boundary filter)
source allow-list: v3 absent -> Classify(k) -> calibrated p, operating point,
| rationale -> decision rows / report fields
memory build/embed (host) -> example rows, ^ ^ seeded, boundary-filtered memory
excerpts, embeddings in the store ---------------------+-----------+
| |
learn compile/evaluate (host, metered) ---- program artifact (programs/<sha>.json) ---- adopt (maintainer)
compile split, bootstrap demos, instruction search, |
grouped folds, cross-fitted Platt, thresholds, curves packaged: ruleset/decision/program-<profile>.json
experiments (plane Runs with arms) -> arm_outcome rows -> analysis -> adopt | reject | inconclusive
A plane Run per case becomes facts -> va, vb -> agree -> vc -> final -> alerts -> roles? -> features -> embed -> decide -> remember. roles is optional and Model-bound. features is model-free with budget {usd: 0, calls: 0},
as remember and the loop steps are (plane/tasks/loop.py:1-5). embed and decide are Model-bound (one Model
per Task, plane/egress.py:87) and metered. remember ingests the new features and decision rows. Labels are
never computed in a Run. They are built on the host from ground-truth files the sandbox never receives, so no task
that produces a feature can read a label.
New code: src/openultrasast/learn/ (schema.py, features.py, roles.py, labels.py, sources.toml -- done --
and excerpt.py, examples.py (example store), retrieve.py, program.py, compile.py, folds.py,
calibrate.py, evaluate.py, decide.py, experiments.py, compile.toml),
src/openultrasast/plane/tasks/{features,roles,embed,decide}.py, plane/models/openrouter-embedding.yaml,
ruleset/frameworks.toml, ruleset/decision/, and CLI groups ousast learn ... and ousast plane experiment ....
The reconciler is not touched. Its 500-line budget stays (tests/test_plane_reconciler.py).
Components and Interfaces
1. Candidate key and feature record (Requirements 1, 7.2)
Candidate. A candidate is (repo, pin, path, function, family). repo is memory.repo_key
(plane/memory.py:69), function is the enclosing declaration by the repo-facts patterns
(plane/tasks/repo_facts.py:131, <global> outside any function), and family is a families.toml id. It is the
key that already joins alert and verdict rows (improve/memory.py:7: "repo, pin and path::function"). For a
delta the pin is the head, and the record carries base beside it. The key lives in the row envelope, for
joining labels and grouping folds. It is never a feature.
A candidate exists when at least one instrument emitted a signal of that family in that function: a quick-rule
alert, an engine finding, a roles sink, or a verify candidate. A labelled positive with no candidate is a
reach miss. It counts against recall and is reported separately as reach, so the engine is never credited
for sites no instrument surfaced.
Delta mapping (Req 7.3). For base..head, changed hunks (git diff -U0 base head) map to the head functions
that enclose a changed line. Functions whose engine operation compare_evidence marks new/worsened
(push/policy.py:489, CandidateDelta.novelty) are added. Each such function yields one candidate per family
that has a signal there. The base side of the same function supplies the delta.* features. A whole-repository
scan is the same with no base (delta.* is not_applicable).
Features. The feature set is closed and listed in learn/schema.py as FEATURES: tuple[FeatureSpec, ...].
Each spec has a name, a source instrument, a type (bool, int with a cap, float, or enum with a closed
vocabulary), a profile (static or plane), and whether it is a prior. There are no free-text values, no
identifiers and no raw counts of repository-specific tokens.
| Feature | Source (file) | Type | Profile |
|---|---|---|---|
lang.* |
preprocess.detect_language |
one-hot over the languages the engine or quick mode covers | static |
qr.enabled_hits, qr.shadow_hits |
quick scan (plane/tasks/alerts.py:117, findings.quick_scan_findings) |
int, cap 20 | static |
qr.mechanism.<m> |
the hit rules' tags mapped to the closed mechanism vocabulary (benchmarks/pairs/mechanisms.toml ids) |
int, cap 10 | static |
qr.max_precision_estimate |
PatternRule.precision_estimate (ruleset/store.py:33) |
float | static |
qr.prior_hits |
hits of rules tagged framework/library (section 2) |
int, cap 10 | static, prior |
eng.findings |
engine findings in the function (engine_alerts.engine_rows, plane/engine_alerts.py:160; pre-push scan) |
int, cap 10 | static |
eng.rung_max |
finding rung | enum suspicion, model_entailed, entailed |
static |
eng.witness_steps_min |
witness path length | int, cap 50 | static |
eng.sink_kind |
sink entry id mapped to an operation kind | enum (sql, command, code_eval, deserialize, file_path, file_inclusion, outbound_request, redirect, html_output, other) | static |
eng.source_kind |
source entry kind | enum (request, server, raw_body, argv_stdin, inferred_role, prior, other) | static |
eng.sink_origin, eng.source_origin |
where the vocabulary entry came from | enum (language, inferred_wrapper, model_role, prior) | static |
eng.sanitizer_on_path |
discharge seen but not accepted | bool | static |
eng.completion |
questions completed / total for the file | float | static |
eng.degraded, eng.unstable |
degradations; present in only one of two runs | bool | static |
facts.callers |
repo-facts call sites (repo_facts.py:151, cap 40) |
int, cap 40 | static |
facts.caller_files |
distinct caller files | int, cap 20 | static |
facts.entry_distance |
caller-graph hops to an entry point (mapping.analyze_entry_points, mapping.py:123) |
int, cap 6 | static |
facts.is_global |
candidate is <global> |
bool | static |
facts.function_lines |
declaration span | int, cap 500 | static |
roles.sink_confidence, roles.source_in_function |
roles task (section 2) |
float / bool | static (deterministic part), plane (model part) |
delta.novelty |
CandidateDelta.novelty or diff of base/head signals |
enum new, worsened, unchanged, unknown, not_applicable |
static |
delta.changed_lines |
changed lines inside the function | int, cap 200 | static |
delta.sanitizer_removed |
a sanitizer call present at base is absent at head | bool | static |
verify.flag_a, verify.flag_b |
pass verdicts (units.jsonl, plane/tasks/verify.py) |
bool | plane |
verify.flag_c |
tie-break pass (asked only when a/b disagree) | bool | plane |
verify.votes |
flags over the passes asked | int 0-3 | plane |
verify.turns_mean, verify.site_offset |
tool turns; lines between the model's reported site and the candidate line | float / int cap 200 | plane |
agree.final |
agreed, disputed, rejected (plane/tasks/remember.py:73) |
enum | plane |
ms.flagged, ms.operation |
model sink classification (benchmarks/independent/model_sinks.py, moved into roles) |
bool / enum | plane |
Deliberately excluded from features (each is label information or an identity):
site_match(plane/tasks/agree.py:200) andin_fix_range(plane/tasks/alerts.py:137). Both are computed from declared sites and fix ranges.- Every fixed-pin alert. A deployed scan has no fixed pin.
- Declared-site counts and
case.json. - Costs (
usd), which vary with provider pricing, not with the code. - Rule ids, paths, file and function names, commit ids and repository names.
Rule ids enter only through closed mechanism buckets and the prior count. A rule written after one case's miss would otherwise identify that case.
Missing is not negative (Req 1.2). Each record carries instruments: {name: {state, version}} with state in
ran | none | failed | not_applicable, and x: {feature: value | null}. none means no coverage for the language
(plane/tasks/alerts.py coverage, remember.coverage_rows, plane/tasks/remember.py:79-96). failed means
the instrument broke or did not read its input: the engine-alert rule "no result file, no read N files line, or
questions == 0 fails the case loudly" (plane/engine_alerts.py docstring) maps here, never to zero findings.
not_applicable covers two cases: delta.* on a whole-repository scan, and facts.* on a pair excerpt, where no
repository exists. Every feature of an instrument whose state is not ran is null. The engine never imputes a
null: retrieval compares instrument states block by block (section 4.2), and the prompt shows "no coverage",
"failed" or "not applicable" in words.
Profiles. static is what pre-push and scan can compute locally without a model: quick rules, engine,
facts, deterministic roles and delta. plane adds verify, agree and model roles. A program is compiled and
calibrated per profile. A static program never sees verify features, in its prompt or its memory, so it cannot
learn to lean on an instrument that is absent in deployment.
Enforcement (Req 7.2). The builder emits only FEATURES names. validate_record rejects any other key, any
string value outside an enum's vocabulary, and any number outside its type. Tests:
test_learn_schema.py::test_features_are_allow_listed: every emitted key is inFEATURES, and no spec's type is free text.::test_record_is_name_invariant: two fixture repositories that differ only in repository name, paths, file names, function names and commit ids give byte-identicalx.::test_label_fields_never_reach_features:site_match,in_fix_range, fixed-pin rows andcase.jsonfields in the inputs change nothing inx.::test_missing_is_not_zero:coverage: nonegivesnull+state: none, and a covered language with no hit gives0+state: ran.
Versioning. schema_version (an integer, bumped on any spec change) and feature_set_digest (sha256 of the
canonical FEATURES list). instruments.*.version records the ruleset digest, the engine image id
(engine_alerts._image_id, plane/engine_alerts.py:177), the runner image digest (memory.image_digest), the
verify model id and a prompt digest, and the roles model id. That makes every record rebuildable (Req 1.3).
Where records live. A new memory row kind features (KINDS at plane/memory.py:52), per repository and
pin like every other row:
{candidate, family, language, profile, schema_version, feature_set_digest, x, instruments, unit: "pin"|"delta", base?}. The row id is row_id("features", run, task, candidate+family).
Who builds them.
- Plane: task
features(plane/tasks/features.py), no Model, one per case, afterfinalandalerts(androleswhen present). Inputs arefacts.json, the passunits.jsonl,agreed.json(withsite_matchdropped on read),alerts.jsonl(vulnerable pin only,in_fix_rangedropped), the alertssummary.jsoncoverage, androles.json. It writesfeatures.jsonl, whichrememberturns into rows (one more input inremember.rows_for,plane/tasks/remember.py:99). - Host:
learn.features.build_for_scan(root, findings, engine_result, *, base=None), called byscanandpre-push(section 8) and byousast learn features <corpus>to build records for the pair corpus and the development corpus without a Run.
2. Framework knowledge: tagged priors and inferred roles (Requirement 8)
Rule. Hand-written knowledge stays at the language level: superglobals, built-ins, process execution, raw
database drivers (DB-API execute, mysqli_*, pg_*, JDBC), deserialisers and the standard library.
Framework and application roles (request objects, query builders, hooks, sanitisers, guards) are either
inferred per repository or kept as tagged priors, which are features that are off by default.
Tagging. Every [[source]], [[sink]], [[sanitizer]] and [[dispatch]] entry in ruleset/semantic/*.toml
and every quick [[rule]] gains an optional framework = "<id>" or library = "<id>". An entry that mixes
language-level and framework calls is split, so a tag always covers a whole entry. ruleset/frameworks.toml
lists the ids and, per id, a lexicon of symbols. The lexicon has two uses only: the lint test, which fails on an
untagged entry containing a lexicon symbol, and repository stratification (below). It never feeds detection.
Loaders take priors: Literal["off", "all"] | frozenset[str]. The default for the engine model is off. Today's
scan behaviour (fallback, Req 6.2) keeps all.
Current framework-specific entries and their disposition:
| Entry | Where | Tag / action |
|---|---|---|
wpdb sink ($wpdb->get_var, ...) |
ruleset/semantic/php.toml:47-58 |
framework = "wordpress" |
unserialize sink: unserialize + maybe_unserialize |
php.toml:136-138 |
split: unserialize language; maybe_unserialize wordpress |
sql_escape: mysqli_real_escape_string, ..., esc_sql |
php.toml:169-170 |
split: esc_sql wordpress |
wpdb_prepare sanitizer |
php.toml:194-196 |
wordpress |
coerce: intval, ..., absint |
php.toml:207-208 |
split: absint wordpress |
wordpress_hooks dispatch (add_action, apply_filters, ...) |
php.toml:225-242 |
wordpress |
layout php_composer_and_wordpress |
php.toml:244-247 |
product filter (vendored/test paths), not detection; renamed php_layout, untagged |
rule php-wpdb-sql-composition |
ruleset/php/rules.toml:20-29 |
wordpress |
rule php-unserialize (pattern includes maybe_unserialize) |
php/rules.toml:114-125 |
split into php-unserialize (language) and php-maybe-unserialize (wordpress) |
Flask request source (request.args, .form, .json, ...) |
ruleset/semantic/python.toml:5-17 |
flask |
django-request source |
python.toml:24-30 |
django |
orm.raw (objects.raw, RawSQL, extra; text, from_statement) |
python.toml:102-104 |
split: django / sqlalchemy (library) |
path.open (open, os.*, shutil.*; send_file, send_from_directory; FileResponse) |
python.toml:110-112 |
split: language / flask / django |
html.unescaped (render_template_string, Markup; mark_safe, format_html, HttpResponse) |
python.toml:119-121 |
flask / django |
outbound.request (requests.*, httpx.*; urlopen, urlretrieve) |
python.toml:127-129 |
split: library requests/httpx / language |
redirect (redirect, HttpResponseRedirect, url_for) |
python.toml:134-136 |
flask / django |
url_allowlist (url_has_allowed_host_and_scheme, is_safe_url) |
python.toml:145-146 |
django |
pickle.loads entry incl. yaml.load |
python.toml:55-57 |
split: yaml.load library pyyaml |
Express req source (req.query, req.body, ...) |
ruleset/semantic/javascript.toml:5-6 |
express |
html.response (res.send; res.write/res.end) |
javascript.toml:80-82 |
split: express / language (Node core) |
path.open (sendFile; readFile, ...) |
javascript.toml:87-89 |
split: express / language |
redirect (redirect; location.*) |
javascript.toml:129-131 |
split: express / language (DOM) |
prototype.merge, outbound.request, html.escape |
javascript.toml:57-59, 109-111, 148-149 |
library (lodash, axios/needle, escape-html/DOMPurify) |
servlet source, response.write |
ruleset/semantic/java.toml:5-6, 47-49 |
jakarta-servlet |
outbound.request (getForObject, exchange; sendRedirect; openConnection) |
java.toml:54-56 |
split: spring / jakarta-servlet / language |
java.escape (ESAPI.encoder, StringEscapeUtils; getCanonicalPath, normalize) |
java.toml:61-62 |
split: library / language |
| hook-as-source rule | cpg/queries/taint.sc:918-949, semantic/facts.py:73-90 |
general code driven by the dispatch fact ("The registry's vocabulary arrives as a FACT", taint.sc:921); with priors off it is inert unless roles infers a registry |
The maintainer's term counts (php.toml 24, php rules 9, python 16, javascript 5, java 3, 15 WordPress mentions
in semantic/*.py and cpg/queries/*.sc) are covered by these entries. The remaining mentions in .sc and
.py are comments (taint.sc:161, 249, 322-327, 588, 911, 1296; dominance.sc:130; census.sc:9;
facts.py:345). The tagging commit's lint test, not this table, is the authority.
Role inference (Req 8.1). Two parts, both producing per-repository vocabulary with provenance:
- Wrapper inference (deterministic,
learn/roles.py, no model). A function whose parameter reaches, within its body, a call already in the role set becomes a derived sink or sanitizer of the same operation kind, withdepth + 1. The role set starts from language-level entries plus any inferred roles. Derivation iterates over therepo-factscaller map to depth 3. When dependency source is present (vendor/,node_modules/, site-packages in the checkout), it is scanned the same way, so$wpdb->querybecomes a sink because WordPress's ownwpdb::queryreachesmysqli_query, not because a table says so. That holds only when WordPress core is present. A plugin checkout usually lacks it, which is why part 2 exists. - Model roles (plane task
roles, Model-bound,deepseek-flash). The source-only classification ofbenchmarks/independent/model_sinks.pymoves into the package: per product file chunk, which functions and call sites perform an operation of a closed kind on non-constant data. The v2 record: declared site among the candidates in 15/17 cases, fix or site file in 16/17, $14.42 for 17 repositories (selection-v2-model-sinks.json). The output extends to all roles:sink,source,sanitizer,guard,dispatch(a register/apply pair such as a hook registry), withoperation, line and a one-line evidence quote. A chunk whose call fails isunclassified, never "no roles" (themodel_sinks.pyrule). Inpre-pushonly the changed files and the definitions they call are classified.
Roles reach the engine as a per-scan vocabulary overlay in the shape of ruleset/semantic/<lang>.toml entries
with origin = "inferred_wrapper" | "model_role". The engine already takes vocabulary as facts
(taint.sc:921). They also reach the feature record as eng.*_origin and roles.*. Roles are signals. A
model-labelled sink alone is not a finding.
Stratification metadata. Each repository gets frameworks: [ids] from its dependency manifests
(composer.json, a WordPress plugin header, requirements*.txt/pyproject.toml, package.json,
pom.xml/build.gradle) matched against frameworks.toml. This is envelope metadata for folds and reports,
never a feature.
Evaluation (Req 8.2-8.4). The engine is always compiled and reported with priors=off (prior features hidden
from the prompt and from the retrieval distance). A prior group (one
framework id) is kept only through an engine-level A/B (section 6): priors on vs off, paired on the same outer
folds, metric recall at the BLOCK operating point. It is adopted only if the repository-bootstrap CI excludes zero.
Every report carries, per family, the metric with priors off, with the adopted priors, and the difference: the
share of the result that depends on priors. Leave-one-framework-out folds (section 4) give the "framework unseen
in training" row.
3. Label builder (Requirement 2)
ousast learn labels (learn/labels.py) runs on the host only. It writes label rows (a new kind) and
labels/<digest>.jsonl snapshots.
Sources are an allow-list, fail-closed. learn/sources.toml names each usable source, the file that proves
its evaluation was recorded, and the date. A population not listed cannot produce a label. The builder never
opens a population file that is not listed, so population v3 (population-v3-php.toml, reserved) is excluded
without being read. It becomes usable only when a later commit adds it after the v3 result is recorded
(Req 2.3).
| Source | Evaluation record | Status |
|---|---|---|
population-v1 |
benchmarks/independent/results-v1.json |
spent (retuned on, population-v2.toml:9-11) |
population-v2 |
results-v2.json, results-v2-hunter.json, plane-increment-2.json |
spent for tuning (model_sinks.py: "v2 is spent for tuning"); its fold is marked design-informed (section 4) |
dev-php |
benchmarks/push/inputs.json (pmpro introduce/repair/benign), MediaWiki and WP Statistics checkouts |
development |
pairs |
benchmarks/pairs/**/catalog.toml via load_pair_catalog (pairs.py:229) |
development; catalog split recorded, not used for folds |
adjudications |
results-v1.json precision_review, results-v2.json / results-v2-hunter.json precision_sample, verifier-check-2026-09-29.json |
recorded verdicts |
Rules per source.
- Populations. Positive: a candidate on the vulnerable pin that matches the case. Matching uses v2 declared sites
through
agree.matches_declared(plane/tasks/agree.py:45, the parity-proven copy ofevaluate.matches_v2). v1 has no declared sites (0 inpopulation-v1.toml), so it uses the fix-range ruleevaluate.matches(benchmarks/independent/evaluate.py:162). Negative: the same(path, function)on the fixed pin when the fix changed that function (inside the new-sidefixed_ranges,plane/generate.py:106, 227-229). A function the fix did not touch is the same code at both pins and is not a negative. Benign: a candidate in a function changed between the benign base and tip pins that has a finding at the tip and none at the base. It is negative by protocol assumption (protocol v2 "Benign false alert"), with sourcebenign_control. A candidate on a vulnerable pin that matches no site is unlabelled, never negative. - Pairs. Positive: the labelled function (
ExpectedFinding.function/line,benchmark.py:20-30; 407 of 452 labels name a function) on the vulnerable side. Negative: the same function on the fixed side. Pairs markedunscorable(identical twins, known limits,pairs.py:130; 7 today) give no labels. Tiertitle(agent-vfc, 59 pairs, labels read from advisory titles; labelled recall 3.6% in the baseline) is excluded by default and used only as a sensitivity arm. - Deltas. Every population case and every pair also gives delta units. fixed..vulnerable is the introducing direction, and its labelled function is a positive delta candidate. vulnerable..fixed is a repair, whose candidates are negative for BLOCK. benign base..tip is a benign push. These are the primary units for the safety net (Req 7.3).
- Benign history (optional, flagged). Non-security commits from the history of each training repository at
least 90 days away from any fix date give benign deltas at no model cost. They are negative by assumption, carry
source
benign_historyand a weight of 0.5, and every metric is reported with and without them. This is the only cheap way to estimate false blocks per benign push. - Adjudications.
TrueandTrue, privilegedare positive (privileged is kept as a label attribute), andFalse, *is negative. Each keeps the adjudicator (maintainer, or a judge task with its evidence) and the recorded text. - A plane verdict (
agreed,rejected,disputed) is never a label (Req 2.2). It enters only asagree.final.
Label row. {candidate, family, label: 1|0, source, source_ref (case id / pair name / adjudication index), unit: pin|delta, direction?, weight, split (catalog split or population split), group (repository group key), frameworks, created, evidence}. group is the normalised repository, the lower-cased owner/name as the guard
uses (improve/memory.py:89-92). Candidates that share an advisory id (CVE/GHSA) across corpora are merged into
one group, so a repository mirrored under two names cannot straddle folds.
What exists today (counted 2026-09-30 with a scratch script over load_pair_catalog +
split_by_repository; the label-snapshot commit makes the count reproducible):
| Family | Pair repos (positive) | Pairs | Population repos (v1 + v2 + dev) | Adjudicated | Per-family model? |
|---|---|---|---|---|---|
| injection | 43 | 56 | 7 + 10 + 3 | most of the 43 (per-family split not counted) | yes |
| untrusted_destination | 41 | 41 | 3 + 6 + 0 | some | yes |
| path | 24 | 24 | 0 | 0 | yes (Python-heavy) |
| config_secrets | 23 | 24 | 1 + 1 | some | yes, borderline |
| deserialization | 23 | 23 | 0 | 0 | yes (all Python) |
| access_control | 21 | 24 | 0 | 0 | yes, borderline |
| output_encoding | 17 | 58 (40 from one repository, OWASP BenchmarkJava) | 0 | 0 | pooled |
| memory | 7 | 176 (132 openssl, 40 curl) | 0 | 0 | insufficient |
| prototype | 6 | 7 | 0 | 0 | insufficient |
Totals: 427 pairs and 452 labels in 146 repository groups (142 named). Tiers: advisory 250, seeded 115, title 59,
reviewed 3. Provenance: human 237, agent 139, synthetic 51. 59 repositories straddle the catalog's train/holdout
split (split_by_repository report). The engine ignores that split for folds and records it on the label.
By language, the strata with at least 10 positive repositories are Python (untrusted_destination 30, injection 23, deserialization 23, path 17, access_control 15, output_encoding 15) and JavaScript injection (16). PHP has no pairs. Its labels are v1 (4 repositories), v2 (6) and dev-php (3): about 9 injection and 4 untrusted_destination repositories.
Plane-profile rows (verify/agree present) exist for 43 candidates in 15 repositories, all v2. The adjudicated
rows are mostly negative: v1 1/12 true, v2 0/17, v2 hunter 2/14 (results-v1.json summary.precision,
results-v2*.json precision).
What this means:
- The
staticprofile can be evaluated for six families, pooled for output_encoding, and not at all for memory or prototype. - The
planeprofile cannot be evaluated until verify has run on labelled units. The harvest Run in the commit sequence (step 5) runs passes a and b on the 78 scorable, non-title pairs in the verify families (injection, untrusted_destination, config_secrets;verify.OPERATIONS,plane/tasks/verify.py:38-50) on both sides, and on the development cases. That is about 312 + 110 hunts at $0.014 each (validation-46: $0.8567 over 61 hunts), about $6. - PHP is reported as its own stratum only where it reaches 10 positive repositories. Today that is injection at the boundary, and untrusted_destination is "insufficient data".
4. The decision engine: an AI classifier with local memory (Requirements 3, 7.1, 7.3, 7.4)
The engine is a declared language-model program, compiled DSPy-style, not a fitted statistical model (Req 3.1). Maintainer, 2026-10-01: "We will not be able to scale up data" and "we need to have the decision engine on AI classifier not ML. with local memory, dspy style". Choices: DSPy-style in-house (the DSPy library is not a dependency), and retrieval by signals + code embeddings. DeepSeek is the LLM; OpenRouter serves embeddings only (memory note "Model calling was broken 2026-09").
What it builds on:
- the metered client
MeteredClient(plane/budget.py:66-152): usd and calls ceilings,BudgetExhausted->unfinished,AccountError->failed, prices from a Model manifest (prices_from,plane/budget.py:45-54;plane/models/deepseek-flash.yaml: $0.014 cache hit, $0.44 miss, $1.32 output per million tokens); - the Model-bound task pattern of
plane/tasks/roles.py:141-241(injected client, units log, restart skip, exit codes 0/2/3), andbuild_client(plane/tasks/verify.py:335); DeepSeekChatClient(model/endpoint.py:91-150): thinking disabled so temperature is honoured, JSON mode with one empty-content retry, optional logprobs (ChatResponse.mean_logprob,tool_hunter.py:160, 287);OpenRouterEmbeddingClient.embed(provider/openrouter.py:137-170) and[embeddings] model/OPENROUTER_EMBEDDING_MODEL(config.py:85-88, 294-298;.env.example:openai/text-embedding-3-small);- the feature record (
learn/features.py:78-115record,learn/schema.py:231validate_record, theEXCLUDED_FIELDSdeny-list atlearn/schema.py:88-95), roles (learn/roles.py), labels and groups (learn/labels.py:296-320Label,:182Groups, floors at:53-54), andfunction_span(learn/features.py:234) for excerpts; - the store (
plane/memory.py:182-298,KINDSat:55-58,put_facts/get_factsat:225-262, the 1 GiB free-space refusal at:347) and the host seed of a Run's memory snapshot (memory.seed,plane/memory.py:642; the sandbox cannot read the store,plane/tasks/loop.py:8-11).
4.1 The program
Signature (learn/program.py, a frozen dataclass rendered into the prompt and used to parse the answer):
| Field | Direction | Type and bound |
|---|---|---|
code |
in | the candidate function, numbered lines, at most 80 lines and 4,000 characters (section 4.2 excerpt rules); for a delta unit, also the base->head diff of the function, at most 40 lines |
signals |
in | the allow-listed record x and instruments states, rendered as name: value lines, null shown as ": no coverage / failed / not applicable"; validated by validate_record before rendering; nothing from EXCLUDED_FIELDS |
roles |
in | inferred roles of the function (learn/roles.py wrapper roles; model roles in the plane profile): role, operation, line, origin (ORIGINS, learn/schema.py:83) |
family |
in | the candidate's family (from the rule or engine family; unknown allowed) |
verdict |
out | vulnerable | not_vulnerable | unsure |
family |
out | one of the families, or none |
confidence |
out | float in [0, 1], the model's stated probability that its verdict is right |
rationale |
out | at most 3 sentences; must cite at least one line number of code (cited_lines), and may name signals |
The answer is JSON (json_object=True). A reply that does not parse, or cites no line in range, is retried once
at temperature 0; if it still fails it becomes unsure with parse_failed: true in the decision row, never
not_vulnerable ("verify the instrument": a broken answer is not a clean result).
Modules, composed as Program = Retrieve -> Classify(k):
Retrieve(record, excerpt, boundary) -> examples(section 4.2), model-free except for one embedding of the candidate excerpt (cached).Classify(k): the prompt isinstruction + fixed demonstrations(the compiled prefix, identical for every candidate so the provider's prefix cache hits)+ retrieved examples + the candidate. Sample 1 runs at temperature 0; samples 2..k at temperature 0.7, issued concurrently.kis a compiled setting in {1, 3, 5}.- The raw score is
s = mean_j p_jwithp_j = confidence_jforvulnerable,1 - confidence_jfornot_vulnerable, and 0.5 forunsure/parse_failed. The reported verdict is the majority (ties ->unsure); a majorityunsurecan never reach BLOCK.
Client plumbing (small, explicit changes). MeteredClient.complete (plane/budget.py:117-136) forwards only
json_object; it gains temperature and logprobs pass-through. DeepSeekChatClient._call
(model/endpoint.py:127-142) adds temperature to extra beside thinking. No provider seed is relied on.
Embedding calls get a metered wrapper MeteredEmbeddingClient in plane/budget.py that reads the response's
usage.prompt_tokens (OpenRouter returns it; parse_embedding_response, provider/openrouter.py:173, keeps only
the vectors today and is extended to keep usage) and prices it from an embedding Model manifest.
Plane tasks. One Model per Task (egress.policy_for(task, workspaces, model), plane/egress.py:87), so:
embed(new, Modelopenrouter-embedding, egressopenrouter.ai): embeds the case's candidate excerpts missing from the cache, writesembeddings.jsonl. Budget default{usd: 0.05, calls: 20}per case.decide(new, Modeldeepseek-flash): readsfeatures,roles,embeddings.jsonland the seeded memory snapshot (the host writes the boundary-filtered examples and the adopted program into the Run's inputs, theloop snapshotpattern), runs the program, writesdecisions.jsonl. Budget default{usd: 0.25, calls: 300}per case. Exit codes asroles.
A plane Run per case becomes facts -> va, vb -> agree -> vc -> final -> alerts -> roles? -> features -> embed -> decide -> remember.
Host path. learn/decide.py: decide_all(records, excerpts, program, memory, client, embedder, budget) for
scan and pre-push, with the same program code. It needs [models] (a DeepSeek key) and either a store
(OUSAST_MEMORY) or the packaged program; otherwise the fallback (section 8).
4.2 Local memory retrieval (Req 3.2, 3.4, 3.6)
The example store. One example row (a new kind in KINDS) per labelled candidate that has both a label
(ousast learn labels, learn/labels.py) and a feature record: envelope + {candidate, family, language, profile, label, source, group, frameworks, unit, x, instruments, roles, excerpt_sha, label_set_digest}. The excerpt is a
content-addressed blob excerpts/<sha>.txt; the embedding is embeddings/<model-slug>/<excerpt_sha>.json (float32
vector in base64, model id, dimensions, tokens, created). Both use the new put_blob(prefix, data) -> sha /
get_blob(prefix, sha), generalising put_facts/get_facts. Examples are built on the host by ousast learn memory build (model-free) and ousast learn memory embed (metered). The snapshot: verified_pin_labels 424
positive / 436 negative rows in 105 groups, plus conditional negatives (benign_control 72, assumed_benign 4,671)
that become examples only once their condition (a candidate signal at the tip and none at the base) is evaluated
(benchmarks/measurements/2026-09-30-decision-engine-labels/labels.json). Rows name functions and hold third-party
code: the store is local, nothing is committed but counts.
Excerpt rules (learn/excerpt.py): the function body at the label's pin from the clone (labels.Clone,
learn/labels.py:365) via function_span; numbered lines; no path, repository or commit; comments kept, but
advisory ids (_ADVISORY, learn/labels.py:59) and lines matching security_reason (learn/labels.py:277)
inside comments are replaced by <redacted>. Candidate excerpts: at most 80 lines / 4,000 characters, centred on
the changed lines of a delta or the engine/quick hit line. Memory examples: at most 40 lines / 2,000 characters.
Memory examples carry no rationale: code, signals, roles, family and label only (cheaper, and no generated text
that could leak the label; only the compiled demonstrations carry rationales, section 4.3).
Stage 1: signal-profile nearest neighbours. A Gower distance over the allow-listed features of the active
profile (features_for(profile), learn/schema.py:163), computed per instrument block so that "missing" is
explicit rather than imputed:
- For instrument
i, if both records havestate == ran:d_i= mean over its features ofd_f, where a numeric feature is|a - b| / cap(theFeatureSpec.cap; floats in [0, 1] use 1), a bool or enum is0if equal else1, null in both is0and null in one is1. - If the states differ (
ranvsnone/failed/not_applicable):d_i = 1. If both are the same non-ranstate:d_i = 0.failedvsnonecounts as different (1). D = sum_i w_i d_i / sum_i w_i, withw_i = 1for every instrument block in the profile (language is one block). Prior features (FeatureSpec.prior) are dropped whenpriors = off. Weights are a compile-time setting only if an A/B shows a gain; the default is uniform.- Candidates in the eligible pool (after the boundary filter) are ranked by
D, ties bysha256(example_id + seed); the firstn1 = 40of the candidate's family and the firstn1of the other families go to stage 2 (per side since 2026-10-01, so a minority family reaches stage 2; Risks, "First paid slice").
Stage 2: code-embedding re-rank. Cosine similarity of the candidate's excerpt embedding with each stage-1
example's. The re-rank score is r = lam * (1 - D) + (1 - lam) * cos, with lam a compiled setting in {0, 0.5}
(default 0.5). An example or candidate without an embedding (budget, provider failure) keeps its stage-1 order,
and the decision row records retrieval: "signals_only". Embedding model: openai/text-embedding-3-small via
OpenRouter (1,536 dimensions; $0.02 per million input tokens at list price, confirmed by the smoke run's usage).
Cache key sha256(normalised excerpt) per embedding model, so the store is embedded once (~5,600 excerpts x ~900
tokens = ~5.0 M tokens, about $0.10) and each new candidate costs ~1,200 tokens ($0.000024).
Balance rule (k_ret = 6, a compiled setting in {4, 6, 8}): walk the re-ranked list and take examples subject
to: at most k_ret/2 per label, at least 2 of each label when the pool has them; at most 2 per repository group;
at least k_ret - 2 of the candidate's family when the pool has them (the rest from other families as contrast).
If the pool cannot satisfy a minimum, take what exists and record balance: "short".
The evaluation boundary (Req 3.4, 7.1). learn/retrieve.py: eligible(example, target, fold) is the only way
an example enters a prompt, for retrieved examples and for the compiled demonstrations alike:
example.groupis not in the fold's evaluation groups (all of them, not just the target's), and not the target's group after the CVE/alias merge (Groups,learn/labels.py:182);- leave-one-source-out:
example.sourceis not the held-out source; - leave-one-framework-out on F:
F not in example.frameworks; a compiled demonstration tagged F is swapped for its pre-computed alternate (section 4.3); - in evaluation, an example whose embedding cosine with the target is >= 0.98 is also dropped as a near-duplicate (a vendored copy or fork the group merge missed); the count dropped is reported. In deployment it is kept.
- In deployment (
scan,pre-push), a repository whose normalised name matches a corpus group excludes that group from retrieval, and the report says "repository is in the corpus: its own cases excluded".
tests/test_learn_retrieve.py::test_boundary builds fixture folds (outer, leave-one-source-out,
leave-one-framework-out) and asserts, for every target and fold, that every retrieved id and every demonstration id
in the rendered prompt is eligible; a second test asserts that memory.seed for a plane Run writes no example of
the case's group. A pre-call assertion in Classify repeats the check on the ids actually rendered.
New labels enter memory (Req 3.6). Adding an example row changes retrieval at once; nothing is recompiled. Each
decision records the memory_snapshot_digest (improve.memory.index_digest, improve/memory.py:351) so the
effect of new labels is an A/B with two memory snapshots as arms (section 6).
4.3 Compilation, DSPy-style and in-house (Req 3.3)
ousast learn compile --profile P --spec learn/compile.toml produces a compiled program from a frozen data
snapshot. The compile spec is committed before the compile runs (pre-registration).
Compile split. 25% of repository groups (seeded, stratified by family) form the compile split C. C is
never an evaluation fold; it remains memory for the evaluation of other repositories. Inside C, groups are split
60/40 into C_boot (demonstration source) and C_val (scoring). One compile per profile, not one per outer fold:
nested compiles per fold would cost five times as much, and C being disjoint from every evaluation fold keeps the
reported numbers out-of-repository.
Metric (pre-registered, compile.toml). Primary: the balanced Brier score of the raw score s on C_val,
each (family, label) cell weighted equally and each repository capped at 5 effective rows per cell (the weight rule
of the earlier design). Lower is better; unsure and parse_failed score as 0.5. Secondary (reported, not
optimised): recall at precision 0.90 and the share of unsure. Tie (within 0.005): fewer prompt tokens wins.
Bootstrap few-shot. Run the program with no demonstrations (retrieval on, k = 1, temperature 0) on up to 150
candidates of C_boot. Keep as demonstration candidates those whose verdict is right with confidence >= 0.7
and whose rationale cites a line in range and names no identity (repository, path, CVE id: scanned and refused).
The rationale was produced without seeing the label, so a demonstration teaches reasoning, not the answer. From the
pool, draw 4 demonstration sets of m = 3 (one positive, one negative, one of either; families mixed), seeded. For
every demonstration, an alternate from a different framework is stored for leave-one-framework-out folds.
Instruction search. One proposal call gives the model the signature, a glossary of the signals, and 20
summarised bootstrap errors (signals and verdicts, no code), and asks for 6 candidate instructions. With the
hand-written baseline instruction that is 7. Scoring on C_val (60 candidates, k = 1): the 7 instructions with
demo set 1, then the best instruction with demo sets 2-4: 10 configurations x 60 = 600 calls. Then k in {1, 3, 5}
and lam in {0, 0.5} are not searched in the compile: they are A/B variants (section 6), so compile spend stays
bounded.
Budget. About $2.2 per compile (section 4.6); ceiling $3.00 per compile through MeteredClient. A compile
that hits its ceiling records unfinished and produces no artifact.
Reproducibility (Req 3.3). Every model and embedding response is cached as responses/<sha>.json, keyed by
sha256(model id, Model parameters digest, messages, temperature, sample index, json_object). Every random choice
(compile split, demo draws, tie-breaks, subsets) is seeded from compile.toml. Re-running a compile on the same
store snapshot with the cache reproduces the artifact byte for byte at $0 (test_learn_compile.py:: test_reproducible_from_cache, on FileStore and on MinioStore gated as today). Without the cache, temperature 0 is
not a determinism guarantee at the provider; the artifact records the cache digest so a re-run can say whether it
was replayed or re-asked.
4.4 Folds and calibration (Req 3.5, 7.1, 7.4)
Folds. Every split is by group (repository), across all corpora; the label builder's merges hold.
- Outer: grouped K = 5 over the groups outside
C, stratified by family and label, one repetition (cost); intervals come from the repository-cluster bootstrap, not from repetitions. - Leave-one-source-out and leave-one-framework-out as in the earlier design (each population and pair slice
held out whole; framework F held out where F has >= 10 groups). The label snapshot has no framework at 10 groups
today (
by_framework: express 4, flask 2, django 1), so leave-one-framework-out is expected to print "insufficient data" until the corpus grows; it costs nothing until then. - The v2 fold stays marked design-informed.
- Families below the floors (
PER_FAMILY_FLOOR = 20,POOLED_FLOOR = 10,learn/labels.py:53-54) are not evaluated at all and get no engine decision: memory, config_secrets and unknown today (385 rows not spent on).
Calibration. The raw score s is mapped by Platt scaling on logit(clip(s, 0.01, 0.99)). It is
cross-fitted over outer folds: the map applied to fold j is fitted on the out-of-fold predictions of the other
folds (each of which was itself made with memory excluding its own fold), so no map is fitted on the fold it is
checked on. Per family at the per-family floor; pooled with a per-family intercept between the floors. Isotonic and
binned maps are not used: too few positives per family. The adopted map is refitted on all out-of-fold
predictions. The check is unchanged: reliability table (5 quantile bins), slope and intercept with
repository-bootstrap CIs, expected calibration error. Holds when the slope CI contains 1, the intercept CI
contains 0 and ECE <= 0.05; otherwise ADVISORY only, recorded as block.offered = false, reason = "calibration".
The probability stays "calibrated on the labelled mix"; the prevalence-free figures (recall at a point, false
BLOCKs per 100 benign deltas, precision at pi in {0.01, 0.05}) remain primary.
Confidence source alternatives (each an A/B variant, not a default): the verdict token's probability from
logprobs (no extra cost), and the verbal confidence alone at k = 1. The default is the self-consistency mean
at k = 5 if the smoke run measures prefix-cache hits on samples 2..k (hit share >= 0.8 of their prompt tokens);
otherwise k = 1 with verbal confidence, because uncached repeats cost 5x (section 4.6).
4.5 Operating points and explanations (Req 7.3, 6.1)
Operating points are read from the calibrated probability, chosen on the cross-fitted predictions of the folds other than the one reported (nested, as before):
- BLOCK: the lowest threshold whose precision has a Wilson 95% lower bound >= 0.95 among delta units, and the
majority verdict is not
unsure. If none exists,block.offered = false, reason = "precision_unreachable". - ADVISORY: the highest threshold reaching recall >= 0.90. Its precision is reported, not assumed.
- DROP: below ADVISORY.
Both are reported on outer folds with recall, precision and false BLOCKs per 100 benign deltas, each with a repository-cluster bootstrap 95% interval (2,000 resamples).
Each decision carries: the calibrated p, the operating point, the program id, the majority rationale and its
cited_lines, the signals the rationale names (checked against the record; names not in the record are dropped),
the retrieved neighbours as counts by label and family (never identities), and retrieval mode. These replace the
logistic "top contributing features" of the earlier design in reports (Req 6.1).
4.6 Cost model
Prices: deepseek-flash $0.014 / $0.44 / $1.32 per million cache-hit / miss / output tokens; embeddings $0.02 per
million. Token sizes are estimates to be replaced by the smoke run's measured usage:
| Prompt part | Tokens | Cached |
|---|---|---|
| instruction | ~500 | yes (shared prefix) |
| 3 compiled demonstrations (40-line excerpt, signals, label, rationale) | ~2,250 | yes |
| 6 retrieved examples (~750 each) | ~4,500 | no |
| candidate (80-line excerpt ~1,100, signals and roles ~300) | ~1,400 | no |
| output (JSON, rationale <= 3 sentences) | ~200 | -- |
- Per candidate,
k = 1: 2,750 x 0.014 + 5,950 x 0.44 + 200 x 1.32 per million = $0.0029. - Each extra sample (same prompt, fully cached): 8,700 x 0.014 + 200 x 1.32 per million = $0.0004. So
k = 5is $0.0045 with cache hits, and $0.0146 if the cache does not hit (the smoke run decides the default). - Embedding: $0.000024 per candidate; the whole store about $0.10 once.
- Per compile: bootstrap 150 x $0.0029 = $0.44; proposals
$0.01; scoring 600 x $0.0029 = $1.74; total **$2.2, ceiling $3.00**. - Per full evaluation of one program, evaluated families only (475 verified rows outside memory,
config_secrets and unknown) plus a 200-unit benign-delta sample for false BLOCKs:
- outer folds: 675 candidates, $2.0 at k = 1, $3.0 at k = 5 (cached);
- leave-one-source-out: 475 more, $1.4 / $2.1;
- leave-one-framework-out: $0 today (no framework at the floor);
- total ~$3.4 (k = 1) to $5.2 (k = 5), ceiling $7.
- Learning curves (section 7): 6 memory-size points + 3 source-count points on a fixed 200-candidate subset at
k = 1: 9 x 200 x $0.0029 = ~$5.2, ceiling $7. - A program-variant A/B on 300 paired candidates: arm A replays cached responses from the evaluation ($0); arm B costs $0.9 (k = 1) to $1.35 (k = 5).
- Deployment:
pre-pushwith ~5 candidates is ~$0.02 per push atk = 5; a plane Run case with ~30 candidates ~$0.14.
Against the balance. DeepSeek was $19.6 before the harvest; the harvest's verify passes are $4.5-5.5 expected, $7 ceiling**:
the smoke run (20 candidates, $6 expected and
model roles up to their $10 ceiling, so **$4-14 remains** depending on what the harvest actually spends (read the
provider balance after it ends; do not assume). Affordable now, as one go-ahead of **$0.10, measures cache hits and real token counts), embedding the store ($0.10,
OpenRouter, separate account), one compile ($2.2), and the outer-fold evaluation at the cheaper confirmed $2-3). Leave-one-source-out, the learning curves, program A/Bs and exp-001 (k
($7.7) together need **$18-20 more**
and a top-up. No single run may start whose ceiling exceeds the balance read just before it.
5. Compiled program artifacts and versioning (Requirement 3.3)
A compiled program is one canonical JSON (sorted keys, floats with repr), a content-addressed blob
programs/<sha256>.json, with programs/index.jsonl (every version) and programs/adopted.json (profile -> sha;
a single overwritten object, versioned under MinIO, history in the index on FileStore). On MinIO the object is
tagged kind=program and profile.
{"program_id": "<sha256>", "kind": "llm-program", "schema_version": 1, "feature_set_digest": "...",
"profile": "static" | "plane", "signature": {"digest": "...", "fields": [...]},
"instruction": "<text>", "instruction_source": "baseline" | "proposed:<n>",
"demos": [{"example_id", "excerpt_sha", "label", "family", "rationale", "alternate": "<example_id>"}],
"retrieval": {"distance": "gower-v1", "n1": 40, "k": 6, "lam": 0.5, "balance": {...},
"embedding_model": "openai/text-embedding-3-small", "near_duplicate_cos": 0.98,
"excerpt": {"candidate_lines": 80, "candidate_chars": 4000, "example_lines": 40, "example_chars": 2000}},
"classify": {"model": "deepseek-flash", "model_params_digest": "...", "k": 5, "temperature": [0.0, 0.7],
"confidence": "vote_mean" | "verbal" | "logprob", "max_output_tokens": 300},
"calibration": {"method": "platt-crossfit", "per_family": {...}, "check": {...}},
"operating_points": {"<family>": {"block": {"threshold", "offered", "reason", "target_precision": 0.95},
"advisory": {"threshold", "target_recall": 0.90}}},
"priors": "off" | ["wordpress", ...], "roles": "off" | "deterministic" | "model",
"data": {"memory_snapshot_digest", "label_set_digest", "compile_groups_digest", "sources": [...], "excluded": {...}},
"compile": {"spec_sha256", "metric": "balanced_brier_v1", "configurations": [{"instruction", "demo_set", "score"}],
"usd", "calls", "response_cache_digest", "replayed": true | false},
"evaluation": {"folds_digest", "outer": {...}, "leave_one_source_out": {...}, "leave_one_framework_out": {...},
"strata": {...}, "curves": "curves/<sha>.json"},
"instruments": {"ruleset_digest", "engine_image", "runner_image", "verify_model", "prompt_digest", "roles_model"},
"seed": 0, "code_commit": "<git sha>", "created": "<iso>"}
The memory itself is not in the artifact; memory_snapshot_digest pins which memory the evaluation used. A
decision made later with a different memory snapshot records its own digest.
Adoption stays a maintainer commit: ousast learn adopt <program_id> writes programs/adopted.json and exports
the artifact to src/openultrasast/ruleset/decision/program-<profile>.json. The packaged copy holds demonstrations
with third-party code excerpts: only demonstrations from repositories with a recorded permissive licence are
exported; others are replaced by their alternates or dropped, and the exported program is re-evaluated as its own
variant (the memory-size-0 configuration of section 7 if no store ships). A program whose priors or roles setting
differs from the incumbent is adopted only with a recorded A/B result (section 6).
6. A/B experiments (Requirement 4)
Manifest. plane/experiments/<id>.yaml, a companion file, is referenced by the Run annotation
openultrasast.io/experiment: <id>. Each arm's Tasks carry openultrasast.io/arm: A|B. Annotations are stripped
before ax (plane/manifests.py:4-6), as memory-key is.
id: exp-001-known-callers
hypothesis: removing the known-callers line changes the rate at which verify flags a true site
arms: {A: {task_env: {}}, B: {task_env: {OUSAST_VERIFY_CALLERS: "off"}}} # everything else identical
unit: candidate # candidate | file | repository
pairing: paired # every unit in both arms
cluster: repository # resampling unit for intervals
order: {randomise: per_case, seed: 1} # which arm runs first per case
metric: {primary: "flag_any_pass_rate(positive units)", secondary: ["flag_any_pass_rate(fixed-pin negatives)", "usd_per_candidate"]}
test: {primary: "cluster_bootstrap_paired_diff", secondary: "exact_mcnemar", alpha: 0.05, sides: 2}
mde: 0.15
stopping: {looks: [0.5, 1.0], boundary: "obrien_fleming"}
budget_usd: {total: 15, per_arm: 7.5}
units_file: plane/experiments/exp-001-known-callers.units.jsonl # frozen list, digest recorded
ousast plane experiment register <id> checks the manifest, freezes the units file, and records the sha256 of
both as an experiment row (a new kind, with repo experiments/<id>). It refuses unless both files are committed
at HEAD. ousast plane run refuses a Run with an experiment annotation whose manifest or units digest differs
from the registered one.
Randomisation and pairing. Paired is the default: every unit runs in both arms, and a seeded per-case coin decides which arm's tasks run first, so provider drift over a run does not align with arms. Only when a variant cannot run on the same unit (for example, a change to candidate generation itself) are repositories assigned to arms by a seeded, family-stratified shuffle. Assignment is recorded in the units file.
Outcomes. The experiment-outcome task (no model), after the arms, writes one arm_outcome row per (unit,
arm): outcome, usd from unit_cost, and pass details. Spend per arm is also in the plane's attribution table
(ousast plane status).
Statistics.
- Primary: the paired difference B - A in the per-unit outcome, with a 95% CI from a repository-cluster bootstrap (10,000 resamples of repositories, with the units inside each). Units in one repository are correlated, so a unit-level test would overstate certainty.
- Secondary: the exact McNemar test on discordant units (a binomial test with p = 0.5), reported beside it.
- Sequential: two looks, at half and all of the repositories (in a pre-shuffled order), with O'Brien-Fleming boundaries (|z| >= 2.797 at the first look, 1.977 at the final). Stopping for futility is not binding.
- Decision: adopt B if the final CI excludes 0 in B's favour, and reject B if it excludes 0 against it. For a cost-only change, equivalent means the TOST 90% CI of the detection difference lies within +-0.05 and the cost CI shows a saving. Otherwise inconclusive, and the incumbent stays.
- Engine-level experiments compare compiled program variants: priors on/off, roles,
kin {1, 3, 5}, the confidence source (vote mean, verbal, logprob),lamin {0, 0.5},k_ret, comments kept vs stripped in excerpts, two instructions, two memory snapshots (Req 3.6: the effect of new labels), or a different chat model. They are no longer free. The arms are two programs run on the same candidates in the same outer folds (paired), the unit is the candidate and the resampling cluster the repository. Arm A replays the incumbent's cached responses from its evaluation ($0); arm B is paid: about $0.9-1.35 per 300 paired candidates (section 4.6), with the arm's ceiling in the manifest'sbudget_usd. The metric is recall at BLOCK (or at ADVISORY where BLOCK is not offered), plus false BLOCKs per 100 benign deltas; the balanced Brier score is secondary. A variant that only changes compile-time choices is compiled on the same compile split with the same seed, so the two arms differ in exactly the declared setting. Because model calls are stochastic, each arm records its response-cache digest; a re-analysis replays both arms at $0.
Where results land. An experiment_result row, and benchmarks/experiments/<id>/result.json (arms, units,
the estimates at each look, decision, spend, manifest digest, code commit) in the same commit that records the
decision. Adoption of a winning pipeline variant is a maintainer commit that changes the default (a Task
template's env, or a [models] value) and cites the result's digest. The memory proposer's M1/M2 status edits
(improve/memory.py) become one variant family: a proposed status change runs as an engine-level experiment
instead of going straight to the benchmark gate.
First experiment, fully specified (Req 4.4). The question: plane-increment-2.json records declared sites
flagged in any pass as 17 for the plane vs 19 for the reference, of 20. The plane added the known-callers line
(plane/tasks/verify.py:1-9, 164, CALLERS_CAP = 8 at :34), and the comparison is confounded (other changes,
stochastic passes, v2 is where it was seen).
-
Arms. A is
verifyas today. B isverifywithOUSAST_VERIFY_CALLERS=off, a new switch (about 3 lines) that makescallers_linereturn empty. Same model (deepseek-flash),PER_HUNT = 6,MAX_STEPS = 6, same candidates, samefacts.json, same runner image digest, two passes (a, b) per arm. -
Units: the development corpus, not v2 (v2 generated the hypothesis):
- population v1, 11 cases, whose engine scans are recorded under
~/ousast-results/independent-v1/scans/(44 scans); - pmpro, MediaWiki, WP Statistics (dev-php);
- the 27 full-repository pointer pairs in the verify families (
recipepairs of vibe-py and agent-vfc, tier != title).
That is 41 repositories. No development repository has recorded verify candidates today. They are generated model-free and frozen in the units file: per pin, the fix-changed functions, plus the top engine- and quick-alert functions, up to 8 candidates per pin, on the vulnerable and the fixed pin. Only units where arm A's callers line is non-empty are treated. The primary analysis is restricted to them, and the rest are reported.
- population v1, 11 cases, whose engine scans are recorded under
-
Metric. Primary: the per-positive-unit rate of "flagged in any pass" (the 17-vs-19 metric), with positives from the label builder (fix-range functions on the vulnerable pin). Secondary: the flag rate on fixed-pin negatives (false flags), and usd per candidate.
-
Sample size and MDE. About 55 positive units across 41 repositories (about 28 dev + 27 pointer pairs). With a discordance rate of about 0.15, alpha 0.05 and power 0.8, the paired MDE is
(1.96 + 0.84) * sqrt(0.15) / sqrt(55), about 0.15. The observed gap of 0.10 needs about 115 positive units, so an inconclusive result is the likely and honest outcome. It is registered as such in advance, and a follow-up needs new units, not a continuation. -
Budget. Hunts per pass per arm: 14 dev repositories x 2 pins x up to 3 files (84) + 27 pairs x 2 sides (54) = 138. Four arm-passes give 552 hunts x $0.014, about $7.7 expected. The ceiling is $15 total, $7.5 per arm, enforced by the task budgets (
plane/budget.py). Candidate generation reuses the recorded v1 engine scans. For dev-php it runs the engine once per pin on the host, one container at a time, from a frozen source export.
7. Extrapolation and the one-time v3 check (Requirement 5)
Learning curves over memory size (5.1). ousast learn curve --profile P varies what the program may
retrieve, not what it was compiled with (the compiled instruction and demonstrations stay fixed, so the curve
measures memory):
- By memory size: the eligible memory restricted to a random share of its repository groups, 0%, 25%, 50% and 100%, with two seeded draws at 25% and 50% (6 points). 0% is the program with its demonstrations only, which is also the configuration a user without a store gets (section 5).
- By sources: memory restricted to 1, 2 and all of the label sources (pairs, populations, adjudications), 3 points.
- Every point is evaluated on the same fixed, stratified 200-candidate subset of the outer folds, at
k = 1, with the boundary filter applied (about $5.2 for all 9 points, ceiling $7; section 4.6).
Per point: recall at ADVISORY, recall at BLOCK (where offered), PR-AUC, balanced Brier score, false BLOCKs per 100
benign deltas, and the share of unsure, per family where the family has the floor. Bands are the 2.5/97.5%
quantiles over the draws x a repository-cluster bootstrap. An inverse power law m(n) = a - b * n^-c in the
number of memory groups is fit to each curve; a projection to 1.5x and 2x the memory is reported only as a band,
and only where the bootstrap CI for c excludes 0. A flat curve is a finding: it says memory does not help that
family and the demonstrations carry the result.
Expected detection on unseen code (5.2). Per family and per language (and per framework, Req 8.3): the outer-fold and leave-one-source-out intervals at both operating points, with priors off and with adopted priors. A stratum with fewer than 10 positive repositories or 10 negative repositories, or whose 95% interval on recall is wider than 0.5, is printed as "insufficient data (n repositories)", with no number. Today that applies to memory, prototype, PHP untrusted_destination, and every plane-profile family until the harvest Run.
The v3 check (5.3), exactly once.
-
Before. The engine and every operating point are adopted, and population v3 is still reserved and unread by this spec's code (it is absent from
learn/sources.toml).ousast learn predict --population-name population-v3-phpwritesbenchmarks/independent/prediction-v3.jsonand it is committed:- the adopted program id per profile, its
memory_snapshot_digestand the chat and embedding model ids; feature_set_digest, priors and roles settings;- the operating points;
- the frozen analyzer source hash (
benchmarks/push/freeze_source.sh); - for each family v3 may contain, the predicted recall and precision intervals from the PHP stratum where sufficient, else the pooled-language interval with the label "no PHP stratum";
- the predicted false BLOCKs per 100 benign deltas;
- the reference to
protocol-v3.md, written by the population's owner, not by this spec.
Only the population's name is needed; its file is not opened.
- the adopted program id per profile, its
-
Run. v3 is scored under protocol v3, once, by its owner's runner, with the engine's decisions added to the recorded outputs. The engine's spend on v3 is a model cost (about $0.0045 per candidate at
k = 5); its ceiling is fixed inprediction-v3.jsonfrom the candidate count the owner reports, never by opening v3 files from this spec's code, and needs the maintainer's go-ahead. -
After.
benchmarks/independent/result-v3-engine.jsonholds, per family, the measured point and interval beside the predicted interval,inside | outside, the calibration check on v3, and the reach misses. v3 is then added tolearn/sources.tomlas spent, and its cases may enter memory. Its numbers never again qualify anything.
8. Integration (Requirements 6, 7.3, 7.5)
Lookup. The compiled program comes from, in order:
[decision] programin the config (a path);- the store's
programs/adopted.jsonwhenOUSAST_MEMORYis set (with the store's memory for retrieval); - the packaged
ruleset/decision/program-<profile>.json(demonstrations only, no retrieval memory unless a store is configured; its own calibration, section 5); - none.
The program also needs a chat Model: [models] with a DeepSeek key on the host, the bound Model in a plane Run.
Embeddings are optional: without an OpenRouter key retrieval runs signals_only and says so.
learn/decide.py: load_program(...) -> Program and decide_all(records, excerpts, program, memory, client, embedder, budget) -> list[Decision(p, operating_point, program_id, verdict, rationale, cited_lines, signals, neighbours, retrieval, offered)], all calls through MeteredClient.
ousast scan. A decide stage runs after the findings are complete and before findings.json is written
(cli.py:517-520). It builds static records with build_for_scan from the quick findings, the engine result,
analyze_entry_points and the repo-facts functions (source-only), plus deterministic roles. It scores each
candidate and attaches decision to the finding: {p, operating_point: block|advisory|drop, program_id, verdict, rationale, cited_lines, signals, offered}. Candidates at or above ADVISORY are reported. Dropped ones go to
dropped_by_engine.json, beside shadow_findings.json. Shadow-status findings enter as candidates too, so a rule's
enabled/shadow status changes the report only if the decision changes (Req 6.3). The shadow split at
cli.py:503-508 applies only in the fallback. --fail-on blocked is a new choice (exit 1 on any BLOCK), and
findings keeps its meaning. Markdown, SARIF (properties.decision) and manifest.json (decision: {program_id, usd, fallback, reason}) carry the fields.
ousast pre-push. Candidates are the CandidateDeltas from compare_evidence, enriched with quick-rule
hits and deterministic roles on the changed functions of head and base. admit_candidates (push/policy.py:879)
gains engine: Models | None:
- With a model, the capability-eligibility reasons
capability_unavailable,capability_ambiguous,capability_disabled,capability_unevaluatedandoperation_semantics_mismatchare replaced by the engine's reasons:engine_below_block,engine_block_not_offered(family insufficient or calibration failed), or none. - Every structural reason stays a hard requirement:
comparison_provenance_mismatch,insufficient_rung,unchanged,change_unsupported,witness_unresolved,location_unresolved,family_semantics_mismatch,claim_dischargedanddependency_unresolved(push/policy.py:909-960).PushReportinvariants (push/report.py:48-97) require witnesses, locations, consequence and repair for actionable defects. - With no capability, consequence and repair come from family-level templates shipped beside the model: language-level text, never framework text.
--mode blockingblocks only on BLOCK.--mode advisorylists ADVISORY and above. Each disposition carries{p, operating_point, program_id, verdict, rationale, cited_lines}in the artifact and one line in the compact report (render_report,push/report.py:214).- The 30 s default deadline (
cli.py,--deadline 30.0) covers feature building anddecide. Everything in thestaticprofile is source-only or already computed by the scan. Model roles run only with--model-config.decideissues candidates and theirksamples concurrently (at most 8 requests in flight); a candidate not decided by the deadline is reportedundecided: deadlineand listed as ADVISORY-unscored, never dropped and never blocked. A push's decide budget defaults to{usd: 0.10, calls: 120}.
Plane Runs. The embed and decide tasks (Model-bound, section 4.1) write embeddings.jsonl and
decisions.jsonl; the host seeds decide with the adopted program and the case's boundary-filtered memory;
remember stores decision rows, and ousast plane status prints BLOCK/ADVISORY counts per case.
Fallback (6.2). With no adopted program, no chat Model or key, an exhausted budget, or a family below the floor, today's behaviour applies unchanged: rule statuses, quick/engine findings as they are, pre-push with the empty capability registry. The report says so in one line: "decision engine: not used (no adopted program | no model key | budget exhausted | family insufficient data | calibration failed) -- findings are today's rule output". A family whose BLOCK is not offered still gets ADVISORY decisions.
User adaptation (7.5) is opt-in: [decision] adapt = true. It keeps the user's dismissals in
.openultrasast/decisions/dismissals.jsonl in their repository, as finding fingerprints with the program id and the
excerpt sha. Dismissed fingerprints are suppressed after the engine, and, with at least 10 dismissals, they enter a
user-local memory (examples labelled not_vulnerable, origin: user) that Retrieve reads for that repository
only, beside the corpus memory. It never writes to the memory store; the label builder and the example builder
refuse any row with origin: user. It is evaluated separately: on held-out repositories, by replaying the first half
of their labels as dismissals and measuring the second half, and reported as "adaptation", never merged into the
generalisation numbers.
Data Models
featuresrow: envelope (id, kind, repo, pin, run, task, population, split, image) +{candidate, family, language, profile, schema_version, feature_set_digest, unit, base?, x, instruments}.labelrow: envelope +{candidate, family, label, source, source_ref, unit, direction?, weight, split, group, frameworks, created, evidence}. Written only byousast learn labels.examplerow: envelope +{candidate, family, language, profile, label, source, group, frameworks, unit, x, instruments, roles, excerpt_sha, label_set_digest}. Written only byousast learn memory build; refusesorigin: userand any source not insources.toml.decisionrow: envelope +{candidate, family, profile, program_id, memory_snapshot_digest, p, raw_score, operating_point, offered, verdict, votes, rationale, cited_lines, signals, neighbours: {label: n, family: n}, retrieval: "signals+embeddings" | "signals_only", parse_failed, usd, calls}.experimentrow:{experiment, manifest_sha256, units_sha256, registered, code_commit}.arm_outcomerow:{experiment, arm, unit, outcome, passes, usd, response_cache_digest}.experiment_resultrow:{experiment, look, estimate, ci, mcnemar_p, decision, spend}.KINDS(plane/memory.py:55-58) already holdsfeatures, label, decision, experiment, arm_outcome, experiment_result(task 1) and gainsexample.TAG_FIELDSis unchanged (familyis already a tag).- Blobs:
programs/<sha>.json,programs/index.jsonl,programs/adopted.json,excerpts/<sha>.txt,embeddings/<model-slug>/<sha>.json,responses/<sha>.json,curves/<sha>.json,labels/<sha>.jsonl. - Models:
plane/models/openrouter-embedding.yaml(provider extensionopenrouter, egressopenrouter.ai,secretKeyOPENROUTER_API_KEY,parameters.input_per_m: 0.02, cache and output rates 0). - Files:
learn/sources.toml,learn/compile.toml,ruleset/frameworks.toml,ruleset/decision/program-*.json,plane/experiments/<id>.yaml+.units.jsonl,benchmarks/experiments/<id>/result.json,benchmarks/independent/prediction-v3.json,result-v3-engine.json.
Error Handling
- Every failure of the feature builder is loud. An instrument that did not read its input records
state: failed, and a Run'sfeaturestask fails when every instrument failed for a case. A plausible all-zero record is impossible because zero requiresstate: ran. validate_recordrejects unknown features, out-of-vocabulary enums and label fields, naming the feature and the source file. The prompt renderer refuses a record that did not pass it.- The label builder exits 2 on a source that is not in
sources.toml, a population whose status is not recorded, or a group that straddles an outer fold after the merges. The error names the repository. - The example builder fails a candidate whose excerpt is empty or whose function span is not found (it never
stores an empty excerpt as an example), and reports the count per source;
memory buildexits 2 if more than 10% of a source's labels have no excerpt (an unread clone is not a small memory). - Compile and evaluation refuse a family below the floor and write an "insufficient data" stub, so
decidecan say why. They assert before every call that no rendered example or demonstration is outside the boundary (section 4.2), and before a compile that the compile split and the evaluation folds share no group. - A model answer that does not parse is retried once, then recorded
unsure, parse_failed. A run where more than 5% of answers areparse_failedis reported as an instrument failure, not as a result. - A smoke check precedes every paid run: 3 candidates, the provider's
usagepresent and non-zero, the reply parsed, the embedding dimension as declared. A run whose first calls report zero tokens stops (memory note "Model calling was broken 2026-09": a working-looking zero). BudgetExhaustedends a compile or evaluationunfinishedwith no artifact and no number;AccountErrorends itfailed(plane/budget.py:22-38).decidewith a program whoseschema_version,feature_set_digestor signature digest differs from the builder's falls back, with the reason in the report, and never renders a mismatched record.- Experiments: a registration mismatch refuses the Run. A budget ceiling ends an arm
unfinished(budget.py), and the analysis then reports "incomplete: arm B stopped at $x" and records no decision. put_blobon FileStore applies the 1 GiB free-space refusal (plane/memory.py:347).
Testing Strategy
No test makes a model or embedding call: the chat and embedding clients are scripted fakes (the
tool_hunter.ChatClient protocol, index.EmbeddingClient at index.py:56) that record what was asked.
tests/test_learn_schema.py: allow-list, name invariance, label fields excluded, missing vs zero (section 1).tests/test_learn_labels.py, on fixture populations and pairs (done, task 4):- positives and negatives per rule, including a fixed-pin function the fix did not touch (not a negative), and an unmatched vulnerable-pin candidate (unlabelled);
unscorableand title-tier exclusion;- an adjudication mapping;
- a source not in
sources.toml, which cannot label, checked with a fixture population named like v3 and a test that asserts the builder never opens its file (openmonkeypatched); - CVE-based group merge.
tests/test_learn_excerpt.py: bounds (lines, characters), centring on changed lines, no path or repository in the excerpt, advisory ids and security wording in comments redacted, delta diff rendering.tests/test_learn_retrieve.py:- Gower distance on hand-computed fixtures: both
ran, state mismatch = 1, same non-ranstate = 0, null in one = 1, prior features dropped withpriors = off; - re-rank with
lamin {0, 0.5},signals_onlywithout embeddings; - the balance rule (label, family and per-repository caps;
shortrecorded); test_boundary: for outer, leave-one-source-out and leave-one-framework-out fixture folds, every rendered example and demonstration is eligible;memory.seedwrites no example of the case's group; the near-duplicate drop in evaluation only; the deployment self-exclusion.
- Gower distance on hand-computed fixtures: both
tests/test_learn_program.py: the rendered prompt contains noEXCLUDED_FIELDSname or label value; the shared prefix is byte-identical across candidates; parsing (valid, invalid -> one retry ->unsure, parse_failed; cited line out of range); majority and scores;unsurecannot BLOCK;MeteredClientreceivestemperatureand stops at its ceiling; the response cache key and replay.tests/test_learn_compile.py: bootstrap keeps only correct, confident, identity-free rationales; instruction candidates scored by the balanced Brier metric with ties to fewer tokens; compile split disjoint from evaluation folds;test_reproducible_from_cache(byte-identical artifact on FileStore, and on MinioStore gated as today); ceiling ->unfinished, no artifact.tests/test_learn_folds.py: no group in both an evaluation fold and the compile split or retrieval pool, for outer, leave-one-source-out and leave-one-framework-out folds; determinism by seed.tests/test_learn_calibrate.py: Platt on a known miscalibration, cross-fitting (fold j's map never saw fold j), the slope/intercept/ECE check, and the ADVISORY-only downgrade.tests/test_learn_decide.py: operating points from cross-fitted predictions, explanation fields (signals named in the rationale but absent from the record are dropped), version-mismatch fallback, deadline ->undecided.tests/test_learn_experiments.py:- registration digest and refusal;
- paired bootstrap CI on a fixture with a known difference;
- exact McNemar values against hand-computed binomials;
- O'Brien-Fleming boundaries;
- adopt / reject / inconclusive / equivalent decisions;
- a program-variant experiment whose arm A replays cached responses at $0.
tests/test_semantic_priors.py(done, task 2): the lint test,priors="off"removes exactly the tagged entries, the split entries keep their language-level calls.tests/test_learn_roles.py(done, task 3): wrapper inference to depth 3 on a fixture, and unclassified chunks are never "no roles".tests/test_plane_features.py,test_plane_embed.py,test_plane_decide.py: fixture run directories from the validation-46 shapes.featuresdropssite_match/in_fix_range;embedanddecidemeter, honour budgets and exit 0/2/3;rememberstoresfeatures/decisionrows.tests/test_push_decision.py: admission with an engine replaces only the capability reasons, and every structural reason still blocks admission.PushReportinvariants hold with engine templates.tests/test_scan_decision.py: the scan fallback line (no program, no key, budget),dropped_by_engine.json, and that a shadow/enabled status flip without a decision change leaves the report unchanged.- Regression net: the existing gate, pair and plane tests.
test_benchmark_output_is_byte_identical_to_golden_baselineholds with no program adopted (the fallback is today's path). - Joern-gated tests run only with the mount recipe (memory note "Joern-gated tests run nowhere"). The engine-alert harvest is verified by its own read-count check, not assumed.
Requirement Traceability
| Requirement | Design section | Verified by |
|---|---|---|
| 1.1 one record per candidate from every instrument | 1 | test_plane_features.py, test_learn_schema.py |
| 1.2 missing is not negative | 1 (instrument state, null), 4.2 (distance) |
test_missing_is_not_zero; test_learn_retrieve.py |
| 1.3 versions and digests for rebuild | 1 (versioning), 5 | test_learn_compile.py::test_reproducible_from_cache |
| 2.1 positives/negatives by source | 3 | test_learn_labels.py |
| 2.2 plane verdict is a feature, not a label | 1, 3 | test_label_fields_never_reach_features; label rules |
| 2.3 provenance; reserved populations unusable | 3 (sources.toml, fail-closed) |
test_learn_labels.py (v3-named fixture never opened) |
| 3.1 declared LM program on the metered client; no statistical model | 4.1 | test_learn_program.py, test_plane_decide.py |
| 3.2 retrieval by signals re-ranked by code embeddings; no identities | 4.2 | test_learn_retrieve.py, test_learn_excerpt.py |
| 3.3 compiled on training folds; versioned; reproducible | 4.3, 5 | test_learn_compile.py |
| 3.4 retrieval never crosses the evaluation boundary | 4.2 (boundary), 4.4 | test_learn_retrieve.py::test_boundary, test_learn_folds.py, pre-call assertion |
| 3.5 confidence calibrated on held-out repositories; else ADVISORY only | 4.4 | test_learn_calibrate.py |
| 3.6 new labels improve via memory, measured by A/B | 4.2, 6 | memory-snapshot experiment; memory_snapshot_digest on decisions |
| 4.1 pre-registered experiment | 6 (manifest, register) | test_learn_experiments.py |
| 4.2 paired, assignment and spend recorded | 6 | arm_outcome rows; ousast plane status |
| 4.3 adopt only on a CI excluding zero or equivalence | 6 (decision rule) | test_learn_experiments.py |
| 4.4 known-callers experiment first | 6 (exp-001) | task 7 record |
| 5.1 learning curves over memory size and sources, with bands | 7 | ousast learn curve output; unit test on a fixture |
| 5.2 intervals per family and language; insufficient data | 7 | test_learn_decide.py (floor), report fixture |
| 5.3 v3 checked once, after freezing | 7 | prediction-v3.json committed before result-v3-engine.json |
| 6.1 probability, operating point, contributing evidence in reports | 4.5, 8 | test_scan_decision.py, test_push_decision.py, test_plane_decide.py |
| 6.2 fallback said in report | 8 | test_scan_decision.py |
| 6.3 rule status no longer changes output alone | 8 | status-flip test |
| 7.1 repository-grouped folds across corpora | 3 (group, CVE merge), 4.4 | test_learn_folds.py |
| 7.2 repository-agnostic allow-list with test | 1, 4.2 (excerpt rules) | test_learn_schema.py, test_learn_excerpt.py |
| 7.3 BLOCK/ADVISORY on unseen repositories; delta unit | 1 (delta), 4.5, 8 | outer-fold report; test_push_decision.py |
| 7.4 calibration checked out of repository; else ADVISORY only | 4.4 | test_learn_calibrate.py |
| 7.5 opt-in adaptation outside the numbers | 8 | builders refuse origin: user; separate report |
| 8.1 language-level knowledge; roles inferred | 2 | test_learn_roles.py |
| 8.2 tagged priors, off by default, kept by A/B | 2, 6 | test_semantic_priors.py; program-variant experiment records |
| 8.3 leave-one-framework-out, per framework | 2, 4.4 | test_learn_folds.py; report strata |
| 8.4 share depending on priors | 2, 7 | report columns priors off / adopted / difference |
Commit sequence, mapped to future tasks
Each commit is gated on the full suite's own exit code, ruff and mypy. None changes a reported number until
commit 9 adopts a program, and without an adopted program every path is today's. Steps that spend model money
start only after the maintainer confirms their budget against the balance read just before.
- Schema and features (1.1-1.3, 7.2) -- done.
- Framework tags (8.2, 8.4) -- done.
- Roles (8.1) -- done.
- Labels (2.1-2.3, 7.1) -- done.
- Harvest (1.1, data) -- in progress: verify a/b on the verify-family pairs and the development cases (about $6, ceiling $10), model roles on the candidate files (ceiling $10), engine features on the host.
- Program, memory, compile, first evaluation (3.1-3.6, 5.1-5.2, 7.3-7.4, 8.3): excerpts and the example
store, metered embeddings and the cache, retrieval with the boundary test, the program and response cache, folds,
compile, cross-fitted calibration; then the paid slice (smoke, embed, compile, outer evaluation,
$5, ceiling $7) and, after a second go-ahead, leave-one-source-out and the memory-size curves ($7, ceiling $10), committed underbenchmarks/measurements/<date>-decision-engine-v1/. - Experiments (4.1-4.4):
learn/experiments.py,register,experiment-outcome, the Run annotation checks,OUSAST_VERIFY_CALLERS, exp-001 registered, run and recorded, and the program-variant experiment machinery. - Integration (6.1-6.3, 7.3, 7.5): the
embedanddecidetasks with the seeded memory, thescanstage,pre-pushadmission with the engine, report fields, the fallback line, and the opt-in adaptation layer. - Adopt (3.3, 8.2):
ousast learn adopt, the packaged program with the licence filter, the priors-on/off andkexperiments with recorded decisions. - v3 check (5.3):
prediction-v3.jsonis committed first, then v3 is run by its owner under protocol v3, andresult-v3-engine.jsonis committed. Nothing between the two commits may touch the program, memory snapshot, features, priors or thresholds.
Risks
-
Label scarcity decides what can be evaluated. The status of each family today:
- memory (5 repository groups, 173/173 rows, almost all openssl and curl), config_secrets (5) and prototype are
insufficient data (
labels.jsonverified_pin_labels). Users get today's behaviour there, labelled as such, and no money is spent evaluating them. - PHP has no pairs and 13 positive population groups. The v3 prediction will mostly be the pooled interval, marked "no PHP stratum", and it will be wide.
- The plane profile has 43 rows from one population until the harvest Run.
- BLOCK at 95% precision is likely not reachable for most families on this data. The design reports "BLOCK not offered" instead of lowering the target.
- memory (5 repository groups, 173/173 rows, almost all openssl and curl), config_secrets (5) and prototype are
insufficient data (
-
Weak signals, strong code. The pair baseline has quick-rule labelled recall 0.24 and Youden -0.013 overall (
pairs.jsonoverall); the v2 engine recall is 0/17 (results-v2.json). The program reads the code, so it can beat the signals, but the verify history says the model over-flags (v1 1/12, v2 0/17 adjudicated true). A calibrated classifier that mostly says "advisory" is honest; recall at ADVISORY 0.90 may need a threshold with poor precision. That is reported, not tuned away on outer folds. -
LLM nondeterminism. The same prompt can answer differently, and temperature 0 is not a provider guarantee. Guards: every response cached and replayable (numbers reproduce at $0); sample 1 at temperature 0; the vote mean over
ksamples as the score; the A/B unit is paired and both arms' caches are recorded; a re-ask agreement rate on a fixed 30-candidate canary set is reported with each evaluation. -
Label leakage into prompts.
- Through rationales: memory examples carry no rationale; demonstration rationales are produced without the label
and scanned for identities and advisory ids; the candidate's prompt never holds
EXCLUDED_FIELDS(test). - Through code: fixed-side excerpts can carry fix comments ("prevent XSS", a CVE id). Advisory ids and security wording in comments are redacted; a comments-stripped arm measures what remains. Delta excerpts show the diff of the change under test, never the later fix.
- Through memorisation: the chat model may have seen public CVE fixes in pre-training. Not removable; the v3 check (unpublished fixes are rarer there) and per-source numbers expose it, and the report names it.
- Through rationales: memory examples carry no rationale; demonstration rationales are produced without the label
and scanned for identities and advisory ids; the candidate's prompt never holds
-
Retrieval leakage. Same repository under another name or corpus: group normalisation, CVE merge, the near-duplicate drop at cosine >= 0.98 in evaluation, and
test_boundary. Demonstrations from the compile split are, by construction, from repositories never evaluated. The seed for plane Runs filters on the host, where the labels are; the sandbox never receives an example of its own case's group. -
Label-tracking signals (maintainer decision 2026-10-01: "Neutralise both for v1"). The harvest found two signals that follow the pair label instead of the code: verify candidates on fixed sides named functions the side no longer declares (now never produced: a side's candidates are only the functions it declares, the rest counted
absent_on_side), and the engine's "read the file, asked no question" state, which a fixed side reaches by losing its sink. The v1 program withholds the engine instrument entirely -- state and values -- from prompts, demonstrations, the instruction proposer and the retrieval distance (input profilev1,ProgramSpec.inputs); the stored record keeps it. The engine returns only through a pre-registered A/B evaluated on assumed-benign and benign-control negatives, where it cannot track a fix.ousast learn audit-leaksreports how well every signal alone separates the sides of a pair and flags any above 0.20 (counts underbenchmarks/measurements/). -
Earlier leakage paths still apply. Hand rules and framework priors written after seeing a training repository (the
$wpdbrule from PMPro, the hook source from WP Statistics,taint.sc:911-949): priors off by default, leave-one-framework-out, best-efforttaught_by = [repo]. The mechanism exporter's train-on-test leak (closed-loop-train-on-test-leak.md): no exporter output is a feature. Verify and tie-break fitted on v2: the v2 fold is design-informed. Thresholds and instructions chosen on reported folds: cross-fitting and the compile split. v3: fail-closed sources, never opened. User dismissals: refused by both builders. -
Cost. Model spend is now per candidate and per evaluation, not one-off. Measured (section 4.6) the first slice fits today's balance; everything after it needs a top-up. Guards:
MeteredClientceilings on every task and command, a smoke check that measures real tokens and cache hits before any paid run, cached replay for re-analysis, unevaluable families skipped,k = 1if the prefix cache does not hit. Deployment cost (~$0.02 per push) is a user-facing change:pre-pushwithout a key falls back, and the report states the spend. -
Model and version drift.
deepseek-flashis an alias whose weights can change, and so can OpenRouter's embedding route. The artifact records model ids, the Model parameters digest and the responsemodelfield; before adoption and before the v3 prediction the canary set is re-asked, and agreement below 0.9 with the cached answers, or an embedding dimension change, invalidates the evaluation (re-embed, re-evaluate) rather than being averaged in. Prices are data in the Model manifest; a price change changes only cost reports. -
Latency. A model call per candidate is slower than a dot product. The 30 s
pre-pushdeadline is met by concurrency and theundecided: deadlinestate; a push with many candidates gets partial decisions, stated. -
Packaging third-party code. Demonstrations and memory hold excerpts of corpus repositories. Only permissively licensed excerpts may ship in the package; the store is never shipped. A user without a store gets the demonstrations-only program, whose own curve point (memory 0%) and calibration are what they are told.
-
Framework inference is itself a model. Model roles cost money and can be wrong.
scanwithout[models]gets only wrapper inference, whose reach on framework-heavy code without dependency source is poor. The report says which role source ran. Both are measured by the roles-on/off experiment. Nothing assumes they help. -
Host time on the 7 GB host. Engine features remain the time cost (7-14 hours serial for the pair corpus, dev-php scans of 200-2,000 s each), run once per engine image from a frozen source export, one container at
--memory 3gat a time, never beside the kind cluster's verify pods, cached by (content sha, image id). Model calls add wall time but no memory pressure; excerpts and embeddings are small (~8 KB per example with a 1,536-float vector, ~45 MB for 5,600 examples). -
Experiments will often be inconclusive. With 41 development repositories the MDE is about 15 points. Pre-registering that and recording "inconclusive" is the intended behaviour. The failure mode to avoid is extending an experiment after looking.
-
First paid slice, 2026-10-01 (tasks 6.2, 6.6; injection only). Four instrument defects surfaced before any number was believed, each fixed with a test:
- The memory build joined no pair. The harvest stores a pair side's record under its unit pin (the blob sha1 of
the side's excerpt), not under
label_pin;ousast learn memory build --units <harvest units.json>joins them. And a candidate'sstaticandplanerecords share a key, so reading both let one overwrite the other and silently drop about half the examples; the build now reads its own profile only. 85 -> 724 examples. - Contrast was unbounded. The balance rule padded a short pick with other families (up to 5 of 6), and stage 1
took the 40 nearest of all families, so a minority family often never reached stage 2. Now stage 1 takes the
n1nearest of the candidate's family and then1nearest others, and at most 2 contrast examples are shown whenever the pool holds the family (maintainer: "allow 2 contrast"); never padded. - Proposed instructions dropped the answer contract, and DeepSeek's JSON mode refuses a prompt without the word
"json" (HTTP 400, the first compile stopped there). The contract (
ANSWER_FORMAT) is now part of the signature and appended to any instruction that lacks it; the baseline prefix is byte-identical. - The adjudication source fails the 10% excerpt gate: 9 of its 43 candidates are anonymous functions (
<lambda>N,<module>) thatfunction_spancannot find by name; the clones were read. Open. The memory is small: 724 static examples (only harvested candidates have feature records; the 97 advisory-fix pairs have none yet), injection 140 (75/65), soC_valhad 13 injection candidates and the compile metric is noisy. The measured ranking signal is real but weak at the recall target: AUC 0.74 out of repository, ADVISORY at recall 0.90 has precision 0.54 (prevalence 0.53); the majorityvulnerableverdict alone has precision 0.86, recall 0.43. Canary re-ask agreement at temperature 0 was 0.83, below the 0.9 the adoption check requires.
- The memory build joined no pair. The harvest stores a pair side's record under its unit pin (the blob sha1 of
the side's excerpt), not under
Maintainer decisions at design approval (2026-09-30)
- Design approved as written.
- Benign pushes: ordinary non-security commits are mined as assumed-benign pushes (no security keyword, no later
fix touching the same lines), kept as their own label source (
assumed_benign), reported separately from the verified negatives, and never merged with them.
Maintainer decisions for the Requirement 3 change (2026-10-01)
- The decision engine is an AI classifier with local memory, not ML: "We will not be able to scale up data" and "we need to have the decision engine on AI classifier not ML. with local memory, dspy style".
- DSPy-style in-house (signature, modules, bootstrap few-shot, instruction search on the plane's metered client), not the DSPy library.
- Retrieval by signals + code embeddings: signal-profile nearest neighbours re-ranked by OpenRouter code embeddings. DeepSeek is the LLM; OpenRouter is used for embeddings only.
Open for the maintainer at design approval:
-
The first paid slice of task 6 (smoke, embed, one compile, outer evaluation): ~$5 expected, $7 ceiling, against the balance read after the harvest.
-
The second slice (leave-one-source-out, memory-size curves): ~$7, ceiling $10, and the top-up it needs.
-
Which corpus licences allow demonstrations to ship in the package (section 5).
-
2026-10-01: revised design approved; packaged demonstrations limited to permissive licences (MIT, BSD, Apache-2.0, ISC, zlib); copyleft and unknown-licence code is used for evaluation and local memory only, never packaged.