From 94e96c6885d82e44b5b8f5aee08850ebac71bbc4 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:34:57 +0200 Subject: [PATCH] chore(p0): remove the improve lever and the plane improvement loop Off every active line (PATHFINDER-2026-10-13/03-unified-proposal.md, cuts). Deleted: src/openultrasast/improve/ (1,175 lines), the ousast improve subcommand, plane/tasks/loop.py and loop.yaml with the --loop generation, the memory-snapshot step in memory.seed, pairs.build_pair_signals, and the four improve tests plus the loop cases in other tests. Docs no longer describe the lever. Net: 3,073 lines deleted, 83 added. Targeted tests: 154 passed; 3,436 tests collect. Co-Authored-By: Claude Fable 5.1 --- README.md | 4 +- docs/architecture.md | 57 +-- docs/deployment.md | 7 +- docs/detection-techniques.md | 3 +- docs/evaluation.md | 2 +- docs/examples.md | 18 +- docs/index.md | 8 +- docs/memory-and-detection.md | 31 -- docs/memory.md | 15 +- docs/plane.md | 26 +- docs/threat-model.md | 6 +- ops/ax/README.md | 7 +- plane/tasks/loop.yaml | 18 - src/openultrasast/cli.py | 186 +------- src/openultrasast/improve/__init__.py | 32 -- src/openultrasast/improve/evolve.py | 416 ------------------ src/openultrasast/improve/journal.py | 53 --- src/openultrasast/improve/memory.py | 549 ------------------------ src/openultrasast/improve/validator.py | 125 ------ src/openultrasast/pairs.py | 14 +- src/openultrasast/plane/generate.py | 143 +----- src/openultrasast/plane/memory.py | 23 +- src/openultrasast/plane/tasks/alerts.py | 5 +- src/openultrasast/plane/tasks/loop.py | 302 ------------- src/openultrasast/ruleset/store.py | 12 +- tests/test_cli_improve.py | 69 --- tests/test_gate.py | 2 - tests/test_improve.py | 324 -------------- tests/test_improve_memory.py | 330 -------------- tests/test_plane_engine_alerts.py | 30 +- tests/test_plane_loop.py | 313 -------------- tests/test_subtraction_complete.py | 26 -- 32 files changed, 83 insertions(+), 3073 deletions(-) delete mode 100644 plane/tasks/loop.yaml delete mode 100644 src/openultrasast/improve/__init__.py delete mode 100644 src/openultrasast/improve/evolve.py delete mode 100644 src/openultrasast/improve/journal.py delete mode 100644 src/openultrasast/improve/memory.py delete mode 100644 src/openultrasast/improve/validator.py delete mode 100644 src/openultrasast/plane/tasks/loop.py delete mode 100644 tests/test_cli_improve.py delete mode 100644 tests/test_improve.py delete mode 100644 tests/test_improve_memory.py delete mode 100644 tests/test_plane_loop.py diff --git a/README.md b/README.md index 0ceb7d2..a8d9cf6 100644 --- a/README.md +++ b/README.md @@ -217,8 +217,7 @@ checks, plus a configuration census. A model answers only the residual question. code it comes from and what it misses. - [docs/architecture.md](docs/architecture.md) gives the stage order and the evidence ladder (a state machine, not a label a model may assign). It also covers the central CWE policy, the - project score and the self-improving loops (`ousast improve`, with `--memory` for proposals from - plane rows). + project score. Quick rules cover C/C++, Python, JavaScript/TypeScript, Java with Groovy templates, and PHP. Each rule runs only against files of its language. @@ -311,7 +310,6 @@ the fold discipline that keeps learned decisions out of their own training data. uv run ousast benchmark benchmarks/manifests/python-vulnerable.toml --mode quick uv run ousast pairs --slice sast # fire on the vulnerable side, stay silent on the fix uv run python -m openultrasast.pair_gate # CI: local pairs must all pass -uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml --dry-run --memory ``` ## Token economics diff --git a/docs/architecture.md b/docs/architecture.md index cee0b8f..a4b3c49 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,7 +5,7 @@ This page covers four things: - how a scan works; - how a finding earns its place; - how severity and the project score are decided; -- how the ruleset improves itself. +- how scan calibration works. State as of 2026-10-02; module paths are under `src/openultrasast/`. @@ -141,9 +141,7 @@ deleted. The score gate works like this: The `score` stage writes `score.json` and merges it into `manifest.json`. -## Self-improvement - -### Scoped calibration (every scan) +## Scoped calibration (every scan) ``` scan ─▶ verify ─▶ non-accepted findings ─▶ scoped learnings @@ -158,54 +156,3 @@ next scan ─▶ rank ─▶ calibrate (demote rejected scopes) ─▶ findings Demotion is scoped and reversible (`tests/test_pipeline_calibration.py`). -### Rule-level loop: `ousast improve` - -Each round (`improve/evolve.py`) runs these steps: - -1. benchmark with the current ledger; -2. per-rule signals (`rule_signals.json`); -3. bounded proposals; -4. validation (`improve/validator.py`); -5. replay smoke; -6. re-benchmark; -7. a hard gate. - -The levers are rule status (`enabled -> shadow` before `disabled`) and score constants. The -loop can never edit pattern text or the authoritative CWE severity. A round is accepted only if -all of these hold: - -- recall >= 90%; -- FP < 10%; -- the project score did not regress; -- no matched finding was lost. - -Otherwise the ledger is not written. With `--pair-catalog`, each round is also gated per -provenance profile on the catalog's holdout pairs. `--no-pair-gate` skips this check; do not -use it for accepted ledgers. The same detection gate runs in CI (`python -m openultrasast.gate`). - -### Proposals from plane memory - -With `--memory [STORE]`, `ousast improve` also reads the rows that plane runs stored per -repository and pin (`improve/memory.py`). Two deterministic rules turn these rows into -rule-status proposals: - -- a rule whose alerts are repeatedly false across repositories is shadowed; -- a shadow rule that keeps hitting agreed, declared vulnerable sites is re-enabled. - -Some rows are dropped before any rule sees them: rows from holdout pairs, from the gated -manifest's own cases and from any `--qualify-population`. Each proposal records the rows it -came from (`memory_proposals.jsonl` next to the journal). It then goes through the same -validator and gate. - -### The loop as a plane Run - -`ousast plane workspaces --validation-set ... --loop` appends the improvement loop -to a generated Run. The Run holds `alerts` per case: the quick scan on the vulnerable and fixed -pins. Then come the steps `snapshot` (task `memory-snapshot`), `measure`, `propose` and -`improve` (`plane/tasks/loop.py`). Each step is model-free with a zero budget. The snapshot -applies the same train-on-test guard before any row leaves the store. A recorded live run: -`benchmarks/measurements/2026-09-30-harnessx-removal-loop/`. - -When that run was made, quick mode covered PHP with zero rules. So for PHP, `ousast plane -alerts-engine` produces a Run's `alerts` from the engine image on the host instead -(`benchmarks/measurements/2026-09-30-php-engine-alerts/`). diff --git a/docs/deployment.md b/docs/deployment.md index e022e8e..d47688a 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -77,8 +77,7 @@ Terms in the table: - **gVisor** is a sandbox runtime that isolates each actor from the node's kernel. - An **actor** is one running Task inside a sandbox. -Only the reconciler talks to the memory store. A sandboxed task cannot read it. So the reconciler seeds -the loop's `memory-snapshot` before the Run starts ([The plane on ax](plane.md)). +Only the reconciler talks to the memory store. A sandboxed task cannot read it. ## A. This host: the local setup (implemented) @@ -478,7 +477,7 @@ Each Task manifest under `plane/tasks/` names the same image by digest. The pin `~/.cache/ousast/ax-src/runner-image`. The manifest picks its work with `spec.command`. The runner starts `python -m openultrasast.plane.tasks.` (`runner.py`, `task_module`). -`repo-facts`, `verify`, `agree`, `features`, `remember`, `alerts`, `loop` and `roles` are modules in +`repo-facts`, `verify`, `agree`, `features`, `remember`, `alerts` and `roles` are modules in that image. They are not images of their own. The engine would be the second image (planned). So the production answer to "how many containers" is: @@ -594,7 +593,7 @@ The worker is the unit. Every Task's `limits` fit the 1 CPU / 1.5 GiB worker of | `verify` | 250m / 384Mi | 1 CPU / 1Gi | 2 (`va`, `vb`), plus 1 (`vc`) on disputed candidates | | `agree` | 100m / 256Mi | 1 CPU / 512Mi | 1 (`agree`; `final` reuses it after `vc`) | | `features`, `remember` | 250m / 512Mi; 100m / 256Mi | 1 CPU / 1Gi; 1 CPU / 512Mi | optional, for the decision engine and the memory | -| `alerts`, `loop` | 250m / 512Mi | 1 CPU / 1280Mi | not in a push Run (loop Runs only) | +| `alerts` | 250m / 512Mi | 1 CPU / 1280Mi | not in a push Run | | `roles` | 250m / 384Mi | 1 CPU / 1Gi | not in a push Run (harvest Runs only) | | `engine` (planned) | from a measured run | above 3 GiB (the host run uses `--memory 3g`) | its own pool, not the default one | diff --git a/docs/detection-techniques.md b/docs/detection-techniques.md index 099c441..4c42c58 100644 --- a/docs/detection-techniques.md +++ b/docs/detection-techniques.md @@ -46,8 +46,7 @@ Rules live in `ruleset//rules.toml` as `[[rule]]` tables. Each has the - optional `framework`/`library`/`fallback_for` tags. The fields are defined in `ruleset/store.py` (`ruleset/store.py:12, -19-41`). The self-improvement loop may change only `status`, `min_evidence_level` and -`precision_estimate`. The pattern is "human-PR-only" (`store.py:23-25`). There are 52 rules: +19-41`). Patterns and rule policy change through reviewed commits. There are 52 rules: C 6, Groovy 1, Java 4, JavaScript 12, Python 11, PHP 18, of which 6 PHP rules are `shadow`. Shadow rules are written to `shadow_findings.json` and removed from the report (`cli.py:538-543`). diff --git a/docs/evaluation.md b/docs/evaluation.md index 5023d5e..91ff082 100644 --- a/docs/evaluation.md +++ b/docs/evaluation.md @@ -143,5 +143,5 @@ per-family tables are on [decision-engine.md](decision-engine.md). ## Removal of the earlier agentic extra The earlier agentic extra was removed on 2026-09-30. The removal was checked for byte-identical -deterministic outputs: pair replay, quick benchmark, standard scan, `improve --dry-run`. Records: +deterministic outputs: pair replay, quick benchmark, standard scan. Records: `benchmarks/measurements/2026-09-30-harnessx-removal-baseline/` and `-equality/`. diff --git a/docs/examples.md b/docs/examples.md index ef41097..bea59f8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -55,19 +55,10 @@ uv run python -m openultrasast.pair_gate # CI: local pairs must all pass Each case is two isolated trees: the vuln file and the patched file, at the same relative path. - `pair_correct` means the harness fired on the vuln side and stayed silent on the fix. -- Misses become `miss` signals and fix-side leaks become `fp` signals for `ousast improve`. +- Misses become `miss` signals and fix-side leaks become `fp` signals in the pair report. - The catalog and public dataset pointers live in `benchmarks/pairs/`. -## 6. Self-improve the ruleset from benchmark feedback - -```bash -uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml --dry-run # preview -uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml # apply -# Accepted edits land in /.openultrasast/calibration/rule_policy.json, -# which the next scan/benchmark of that target loads automatically. -``` - -## 7. Running the agentic plane +## 6. Running the agentic plane Model work over whole repositories runs as a Run manifest on ax. Each task gets one isolated actor. Bring-up is in [ops/ax/README.md](ops/ax/README.md). @@ -80,9 +71,6 @@ uv run ousast plane doctor # kind, Agent Substrate uv run ousast plane run plane/runs/validation-46.yaml # per case: repo-facts, verify a/b/c, agree, final, features, remember uv run ousast plane status validation-46 # per-task status and token attribution uv run ousast plane remember validation-46 # re-ingest the run's memory rows (OUSAST_MEMORY) - -# Feed the stored rows back into the ruleset loop (same validator and gate) -uv run ousast improve benchmarks/manifests/java-spring-boot-vulnerable.toml --dry-run --memory ``` Each task in the Run carries its own Model and `budget: {usd, calls}`. @@ -92,7 +80,7 @@ Each task in the Run carries its own Model and `budget: {usd, calls}`. - `ousast plane status` prints the token attribution table. It shows calls, prompt, cache-hit and output tokens, and USD per task. -## 8. Drive it from an MCP client (OpenCode / IDE) +## 7. Drive it from an MCP client (OpenCode / IDE) MCP (Model Context Protocol) lets an editor or agent call the tool's functions. diff --git a/docs/index.md b/docs/index.md index b6511dc..12016dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -28,9 +28,6 @@ flowchart LR scan --> report["Report: findings with evidence, SARIF, score"] scan -- "alerts on vulnerable and fixed pins" --> plane["Plane on ax: repo-facts, verify a/b/c, agree, features"] plane -- "remember" --> mem[("Memory store: rows, facts, examples, blobs")] - mem -- "improve --memory: rule-status proposals" --> gate{"Validator and detection gate"} - gate -- "accepted round" --> ledger["Ruleset ledger: rule status"] - ledger --> scan labels["Labels from ground truth"] --> mem mem -- "retrieval of similar labelled cases" --> engine["Decision engine: compiled AI classifier"] engine -. "intended, not adopted: BLOCK / ADVISORY" .-> report @@ -41,10 +38,7 @@ flowchart LR - The **plane** runs model work per case: verify passes and roles. It also runs model-free bookkeeping per case: facts, agreement and feature records. Its `remember` task turns a case's artifacts into rows of the [memory store](memory.md). -- Memory feeds two consumers: - 1. `ousast improve --memory` turns stored rows into rule-status proposals. They pass through the - same validator and gate as every other edit. - 2. The decision engine retrieves labelled examples from the same store. +- The decision engine retrieves labelled examples from the memory store. - The decision engine's output (dotted line) is the intended path. Nothing user-facing reads it today. diff --git a/docs/memory-and-detection.md b/docs/memory-and-detection.md index 4fc01fe..d6c3dc0 100644 --- a/docs/memory-and-detection.md +++ b/docs/memory-and-detection.md @@ -149,37 +149,6 @@ Calibration **holds** when the slope interval contains 1, the intercept interval **BLOCK** is offered only when calibration holds and a fold reaches the required precision. Otherwise the engine offers **ADVISORY** only. -## The loop: memory improves decisions without retraining - -```mermaid -flowchart LR - new["New labels and adjudications"] --> labels["ousast learn labels"] - labels --> build["ousast learn memory build, embed"] - build --> mem[("Memory store: example rows")] - mem --> retr["Retrieved per candidate at decision time"] - retr --> prog["Same compiled program"] - prog --> dec["Decision"] - runs["Plane runs: alert, verdict, coverage rows"] --> mem2[("Memory store: run rows")] - mem2 --> imp["ousast improve --memory: M1, M2 proposals"] - imp --> gate{"Unchanged validator and gate"} - gate -- "accepted round" --> ledger["Rule status in the ledger"] -``` - -- **Decisions.** - - The compiled part of the program is its instruction and demonstrations. - - The examples are retrieved from memory for each candidate. - - So a new `example` row is eligible for retrieval in the next decision without recompiling. The same - evaluation boundary applies. - - `ousast learn curve` measures the metric over memory size on a fixed candidate subset. -- **Rules.** `ousast improve --memory` reads the run rows (alerts, verdicts, coverage). It proposes - rule-status edits only: - - shadow a rule that is repeatedly false across repositories; - - re-enable a shadow rule that keeps hitting agreed declared sites. - - Some rows are dropped first: rows from holdout pairs, the gated manifest's own cases and any - `--qualify-population`. Every proposal goes through the same validator and gate as any other edit - (`improve/memory.py`). - ## What the first slice measured Record: `benchmarks/measurements/2026-10-01-decision-engine-injection-slice/record.json` (counts diff --git a/docs/memory.md b/docs/memory.md index 421e964..f396645 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -25,10 +25,9 @@ replaces itself. The kinds (`KINDS`) and who writes them: | `facts` | `remember` task | the facts entry of a case (points at `facts/.json`; reused by memory key) | | `verdict` | `remember` task | per candidate: each verify pass's answer, the final outcome, whether a tie-break ran, cost | | `unit_cost` | `remember` task | per verify pass and file: usd, calls and token usage | -| `alert` | `remember` task | a quick-scan or engine alert on the vulnerable or fixed pin (loop Runs) | +| `alert` | `remember` task | a quick-scan or engine alert on the vulnerable or fixed pin | | `coverage` | `remember` task | per language and pin: whether any rule could have fired there | | `features` | `remember` task, from the `features` task | one feature record per candidate and family | -| `proposal_outcome` | `ousast improve --memory` (not with `--dry-run`), and the loop's `improve` step | what happened to each memory proposal in a round | | `example` | `ousast learn memory build` | a labelled case: label + feature record + excerpt sha (no rationale) | | `experiment` | `ousast learn experiment register` (task `register`) and `run` (task `run`) | a registered manifest's digests and provenance; a run's counts-only summary. Repo `experiments/`, pin the registering commit | | `arm_outcome` | `ousast learn experiment run` | one decision per (unit, arm, replicate): score, verdict, usd, order | @@ -52,8 +51,6 @@ flowchart LR tasks["Plane tasks: facts, verify a/b/c, agree, alerts, features"] --> rem["remember task: memory.jsonl"] rem --> recv["Delivered to the host with the run's artifacts"] recv --> ingest["ingest after ousast plane run, or ousast plane remember RUN"] - loop["Loop improve step: proposal_outcome"] --> ingest - improve["ousast improve --memory: proposal_outcome"] --> put build["ousast learn memory build: labels + features + excerpts"] --> put["put_rows, put_blob"] embed["ousast learn memory embed"] --> put compile["ousast learn compile, evaluate"] --> put @@ -79,26 +76,16 @@ flowchart LR flowchart LR store[("Memory store")] --> seed["seed before a Run"] seed --> reuse["Facts reused by memory key: repo, pin, candidates, runner image"] - seed --> snap["memory-snapshot: rows after the train-on-test guard"] store --> examples["learn: load example rows, excerpts, embeddings"] examples --> retr["Retrieval of similar labelled cases"] store --> responses["Response cache: replay at no cost"] - store --> imp["ousast improve --memory: alert, verdict, coverage rows"] - imp --> guard["Train-on-test guard"] - guard --> rules["Rules M1, M2 to rule-status proposals"] - rules --> gate{"Unchanged validator and gate"} ``` - **Seed** (`memory.seed`). - A Run task gets stored facts when its `openultrasast.io/memory-key` matches them. It is then marked done. - A changed pin, candidate set or runner image finds nothing and recomputes. - - The loop's `memory-snapshot` is seeded the same way. A sandboxed task cannot read the store. - **Retrieval** (`learn/retrieve.py`): see [Memory and detection](memory-and-detection.md#3-retrieval-of-similar-labelled-cases). -- **`improve --memory`** (`improve/memory.py`). Some rows are dropped before any rule sees them: - rows from holdout pairs, from the gated manifest's own cases, and from any `--qualify-population`. See - [Architecture](architecture.md#proposals-from-plane-memory). - ## Backends: FileStore and the S3 store `OUSAST_MEMORY` selects the backend (`open_store`): diff --git a/docs/plane.md b/docs/plane.md index 84aec7d..a857824 100644 --- a/docs/plane.md +++ b/docs/plane.md @@ -103,11 +103,10 @@ What each step guarantees: The first resume of a new image can time out while the snapshot is built. So the reconciler retries it for up to `OUSAST_RESUME_TIMEOUT` seconds (900). -## The Runs: validation and loop +## Validation Runs `ousast plane workspaces POPULATION --validation-set ...` (`generate.py`) writes one chain per case. -`--loop` adds the `alerts` step per case and the loop's singleton steps. Every step is model-free -except the verify passes (and `roles` in harvest Runs). +Every step is model-free except the verify passes (and `roles` in harvest Runs). ### Per case @@ -122,33 +121,15 @@ flowchart LR vb --> final vc --> final final --> features["features: one record per candidate"] - alerts["alerts: quick scan on vulnerable and fixed pins (--loop)"] -.-> features features --> remember["remember: memory.jsonl"] - alerts -.-> remember final --> remember ``` -### The loop (with `--loop`) - -```mermaid -flowchart LR - store[("Memory store")] -- "seeded before the Run, after the train-on-test guard" --> snap["memory-snapshot"] - rem["remember of every case"] --> measure["loop-measure"] - snap --> measure - measure --> propose["loop-propose: rules M1 and M2, M3 advisory"] - snap -- "index.json" --> propose - propose --> improve["loop-improve: one evolve round, unchanged validator and gate"] - improve --> outcome["gate.json, journal.json, and rule_policy.json only if accepted"] -``` - - **Before the Run**, `ousast plane run` seeds from the store (`memory.seed`). - Each task has an `openultrasast.io/memory-key`: repository, pin, candidates digest and runner image digest. - If the store has facts for that key, the task gets them and is marked done. - - The sandboxed `memory-snapshot` task gets the store's rows after the guard. - **After the Run**, it ingests every delivered `memory.jsonl`. `ousast plane remember RUN` repeats this by hand. See [Memory](memory.md). -- [Architecture](architecture.md#proposals-from-plane-memory) describes the loop's rules and its guard. - Adopting an accepted ledger stays a maintainer commit (`src/openultrasast/plane/tasks/loop.py`). - `ousast plane harvest` writes the decision engine's harvest Runs (verify a/b with agree, and model `roles`). - `ousast plane alerts-engine` produces a Run's `alerts` from the Joern engine image on the host. It @@ -174,8 +155,7 @@ draws it and sizes the pre-push hook at scale. | `agree` | none | a and b agreed or disputed; after pass c on the disputed, 2-of-3 (the step `final`) | | `features` | none | one feature record per candidate for the decision engine | | `remember` | none | the case's artifacts as memory rows | -| `alerts` | none | the quick scan on the vulnerable and fixed pins (loop Runs) | -| `loop` | none | the steps `snapshot`, `measure`, `propose`, `improve` | +| `alerts` | none | the quick scan on the vulnerable and fixed pins | | `roles` | `deepseek-flash` | per-repository source, sink and sanitizer roles without a vocabulary | Source: [ax on this host](ops/ax/README.md#tasks). diff --git a/docs/threat-model.md b/docs/threat-model.md index 0088e77..d978247 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -14,10 +14,8 @@ It also lists the controls that keep a scan safe to run in CI and safe to share. - Only `deep` mode executes target-derived code. Its REGRESS stage loads promoted candidates inside the Docker sandbox described below. It runs only when `docker info` succeeds (otherwise the stage is skipped and recorded as `sandbox_unavailable`). -- **The ruleset and CWE policy are trusted, governed data.** They change in only two ways: - through reviewed commits, or through the bounded self-improvement loop (`ousast improve`). The - loop can flip rule *status* and tune score constants. It can never edit pattern text or the - authoritative 0–5 severity. See [architecture.md](architecture.md#self-improvement). +- **The ruleset and CWE policy are trusted, governed data.** They change + through reviewed commits. - **Provider credentials are the operator's.** The core install (PyYAML only) makes no network calls on its own. - A local scan calls a model only in standard or deep mode, and only when a key is available. diff --git a/ops/ax/README.md b/ops/ax/README.md index 526903b..c6bfb33 100644 --- a/ops/ax/README.md +++ b/ops/ax/README.md @@ -74,12 +74,11 @@ and to the hosts the bound Model declares (`openultrasast.io/egress-hosts`). The | `agree` | none | a and b agreed or disputed; after pass c on the disputed, 2-of-3 (the step `final`) | | `features` | none | one feature record per candidate for the decision engine | | `remember` | none | the case's artifacts as memory rows (`facts`, `verdict`, `unit_cost`, `alert`, `coverage`, `features`) | -| `alerts` | none | the quick scan on the vulnerable and fixed pins (loop Runs) | -| `loop` | none | the steps `snapshot`, `measure`, `propose`, `improve`: the improvement loop as a Run | +| `alerts` | none | the quick scan on the vulnerable and fixed pins | | `roles` | `deepseek-flash` | per-repository source, sink and sanitizer roles without a vocabulary (decision-engine harvest) | Generators: `ousast plane workspaces --validation-set ...` writes the per-case chain -(`facts -> va, vb -> agree -> vc -> final -> features -> remember`, plus `alerts` and the loop with `--loop`); +(`facts -> va, vb -> agree -> vc -> final -> features -> remember`); `ousast plane harvest --labels ... --plane DIR` writes the decision engine's harvest Runs; `ousast plane alerts-engine` produces a Run's `alerts` from the Joern engine image on the host, for PHP and other languages quick mode does not cover. @@ -98,7 +97,7 @@ is skipped by the index. A facts entry is reused for the same repository, pin, c tested one). Endpoint and credentials come from `.env` or the environment (`S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`), never from a manifest, and are never printed. -`ousast improve --memory [STORE]` and the `ousast learn` commands (`--memory`) read the same store. +The `ousast learn` commands (`--memory`) read the same store. ## Measured footprint (2026-09-29, idle, two workers) diff --git a/plane/tasks/loop.yaml b/plane/tasks/loop.yaml deleted file mode 100644 index 3650ce7..0000000 --- a/plane/tasks/loop.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# loop (harnessx-removal Req 6.3, design section 6): the improvement loop's singletons -- the generator appends the -# step (snapshot, measure, propose, improve) to the command. No model, budget {usd: 0, calls: 0}; improve replays the -# benchmark gate unchanged and delivers its verdict as gate.json. The image is digest-pinned. -apiVersion: ax.io/v1alpha1 -kind: Task -metadata: - name: loop - atespace: default -spec: - image: "localhost:5001/ousast-runner@sha256:d2a55293999cc34581ef1f02a1ffb0c2dd8fc0cbe001ac952df81600e868f019" - command: ["loop"] - resources: - requests: - cpu: "250m" - memory: "512Mi" - limits: - cpu: "1" - memory: "1280Mi" diff --git a/src/openultrasast/cli.py b/src/openultrasast/cli.py index f158b07..0d2c8e2 100644 --- a/src/openultrasast/cli.py +++ b/src/openultrasast/cli.py @@ -6,7 +6,6 @@ import logging import os import subprocess import sys -import tempfile import time from collections.abc import Mapping, Sequence from dataclasses import asdict, dataclass, replace @@ -39,10 +38,8 @@ from .complexity.ledger import persist_verdicts from .config import ModelLayerConfig, ObligationsConfig, ResolvedConfig, RetiredConfigError, load_config, load_dotenv from .findings import StaticFinding, quick_scan_findings, uncovered_languages, write_findings from .fusion import FusionDecision, fuse_findings -from .gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR from .harness import HarnessRuntime, HarnessTraceWriter, write_harness_config from .hunter import run_hunter_pool, write_hunter_trajectories -from .improve import RoundOutcome, run_improvement from .learn.cli import add_commands as add_learn_commands from .learn.cli import run as run_learn from .mapping import analyze_entry_points, attach_reachability_hints, ingest_sarif, write_entry_points, write_static_hints @@ -234,55 +231,9 @@ def _main(argv: list[str] | None) -> int: benchmark.add_argument("--mode", choices=("quick", "standard", "deep"), default="quick") benchmark.add_argument("--config", type=Path, default=Path("openultrasast.toml")) - improve = subparsers.add_parser( - "improve", - help="run the bounded self-improvement loop against a benchmark manifest and update the loop-owned ruleset ledger", - ) - improve.add_argument("manifest", type=Path) - improve.add_argument("--max-rounds", type=int, default=5) - improve.add_argument("--recall-floor", type=float, default=RECALL_FLOOR) - improve.add_argument("--fp-ceiling", type=float, default=FALSE_POSITIVE_CEILING) - improve.add_argument( - "--ledger", type=Path, default=None, help="ruleset ledger path (default: /.openultrasast/calibration/rule_policy.json)" - ) - improve.add_argument("--journal", type=Path, default=None, help="improvement journal path (default: alongside the ledger)") - improve.add_argument( - "--ruleset-dir", type=Path, default=DEFAULT_RULESET_DIR, help="ruleset directory to improve (default: the bundled ruleset)" - ) - improve.add_argument("--dry-run", action="store_true", help="run rounds against a throwaway ledger; never touch the target's ledger") - improve.add_argument( - "--pair-catalog", - type=Path, - default=DEFAULT_CATALOG, - help="pair catalog whose holdout split gates every round per provenance profile", - ) - improve.add_argument( - "--no-pair-gate", action="store_true", help="skip the per-profile holdout clause (faster; not for accepted ledgers)" - ) - improve.add_argument( - "--profile-tolerance", type=float, default=0.0, help="allowed per-profile drop in pair_correct/Youden before rejecting" - ) - improve.add_argument("--min-holdout-pairs", type=int, default=5, help="profiles with fewer holdout pairs are reported, not gated") - improve.add_argument( - "--memory", - nargs="?", - const="", - default=None, - metavar="STORE", - help="also propose rule-status edits from the plane memory store (a directory, file:// or s3:// URL; " - "no value: OUSAST_MEMORY or the default store); off by default", - ) - improve.add_argument( - "--qualify-population", - action="append", - default=[], - metavar="NAME", - help="population a memory proposal will be qualified on; its rows never propose (repeatable)", - ) - pairs = subparsers.add_parser( "pairs", - help="scan isolated vuln-vs-fixed pairs (TP on vuln, silent on fix) and emit improve-loop signals", + help="scan isolated vuln-vs-fixed pairs (TP on vuln, silent on fix) and report misses and fixed-side false positives", ) pairs.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG) pairs.add_argument("--slice", choices=SLICE_NAMES, default="all") @@ -392,17 +343,6 @@ def _main(argv: list[str] | None) -> int: plane_ws.add_argument( "--runner-image", type=Path, help="with --validation-set: re-pin the task templates to the image in this file (ops/ax/up.sh)" ) - plane_ws.add_argument( - "--loop", action="store_true", - help="with --validation-set: append the improvement loop (alerts per case, then snapshot, measure, propose, improve)", - ) # fmt: skip - plane_ws.add_argument("--loop-commit", help="with --loop: the commit under test the gate runs at (default: git rev-parse HEAD)") - plane_ws.add_argument("--loop-repo", help="with --loop: this repository's URL (default: git remote get-url origin)") - plane_ws.add_argument("--loop-manifest", help="with --loop: the benchmark manifest the proposals are gated on") - plane_ws.add_argument( - "--qualify-population", action="append", default=[], metavar="NAME", help="with --loop: a population whose rows never propose" - ) - raw = list(argv if argv is not None else sys.argv[1:]) if _advisory_hook(raw): # Git hook mode (stdin) without an explicit blocking opt-in: a check that cannot run must @@ -430,23 +370,6 @@ def _main(argv: list[str] | None) -> int: return _scan(args.path, args.config, args.mode, args.fail_on) if args.command == "benchmark": return _benchmark(args.manifest, args.config, args.mode) - if args.command == "improve": - return _improve( - args.manifest, - max_rounds=args.max_rounds, - recall_floor=args.recall_floor, - fp_ceiling=args.fp_ceiling, - ledger=args.ledger, - journal=args.journal, - ruleset_dir=args.ruleset_dir, - dry_run=args.dry_run, - pair_catalog=None if args.no_pair_gate else args.pair_catalog, - profile_tolerance=args.profile_tolerance, - min_holdout_pairs=args.min_holdout_pairs, - memory=args.memory, - qualify_populations=tuple(args.qualify_population), - guard_catalog=args.pair_catalog, - ) if args.command == "pairs": return _pairs( args.catalog, @@ -1103,75 +1026,6 @@ def _benchmark(manifest_path: Path, config_path: Path, mode: str) -> int: return 0 -def _improve( - manifest_path: Path, - *, - max_rounds: int, - recall_floor: float, - fp_ceiling: float, - ledger: Path | None, - journal: Path | None, - ruleset_dir: Path, - dry_run: bool, - pair_catalog: Path | None = None, - profile_tolerance: float = 0.0, - min_holdout_pairs: int = 5, - memory: str | None = None, - qualify_populations: tuple[str, ...] = (), - guard_catalog: Path | None = None, -) -> int: - if not manifest_path.exists() or not manifest_path.is_file(): - raise SystemExit(f"benchmark manifest is not a file: {manifest_path}") - pair_cases: tuple[object, ...] = () - if pair_catalog is not None: - if not pair_catalog.is_file(): - raise SystemExit(f"pair catalog is not a file: {pair_catalog} (pass --no-pair-gate to skip the per-profile holdout clause)") - pair_cases = select_split(load_pair_catalog(pair_catalog), "holdout") - manifest = load_benchmark_manifest(manifest_path) - target = resolve_benchmark_source(manifest_path, manifest) - policy = load_policy() - session = _memory_session(memory, manifest, target, ruleset_dir, qualify_populations, guard_catalog) if memory is not None else None - - # The loop writes its accepted ledger where `scan`/`benchmark` read it, so a - # subsequent scan of this target automatically picks up the improved ruleset. - default_ledger = target / CALIBRATION_DIR / "rule_policy.json" - with tempfile.TemporaryDirectory(prefix="ousast-improve-") as scratch: - if dry_run: - ledger_path = Path(scratch) / "rule_policy.json" - journal_path = Path(scratch) / "improve_journal.json" - else: - ledger_path = ledger or default_ledger - journal_path = journal or ledger_path.with_name("improve_journal.json") - - outcomes = run_improvement( - target, - manifest, - ledger_path=ledger_path, - journal_path=journal_path, - ruleset_dir=ruleset_dir, - policy=policy, - max_rounds=max_rounds, - recall_floor=recall_floor, - fp_ceiling=fp_ceiling, - pair_cases=pair_cases, - profile_tolerance=profile_tolerance, - min_holdout_pairs=min_holdout_pairs, - proposals=session.proposals(ledger_path, journal_path) if session else (), - ) - _print_improve_outcomes(outcomes, manifest_path, target, ledger_path, dry_run=dry_run) - if session is not None: - session.finish(outcomes, journal_path, dry_run=dry_run) - return 0 - - -def _memory_session( - memory: str, manifest: object, target: Path, ruleset_dir: Path, populations: tuple[str, ...], catalog: Path | None -) -> Any: - from .improve.memory import MemorySession # lazy: the default `improve` path never touches the store - - return MemorySession.open(memory, manifest, target, ruleset_dir, populations, catalog) - - def _pairs( catalog: Path, slice_name: str, @@ -1473,29 +1327,6 @@ def _check_obligations( return statics, payload, cited -def _print_improve_outcomes(outcomes: list[RoundOutcome], manifest_path: Path, target: Path, ledger_path: Path, *, dry_run: bool) -> None: - print(f"manifest={manifest_path}") - print(f"target={target}") - accepted = [o for o in outcomes if o.accepted] - for outcome in outcomes: - edits = ", ".join(f"{e.rule_id}:{e.from_status}->{e.to_status}" for e in outcome.edits) or "-" - print( - f"round {outcome.round}: {outcome.reason} | " - f"recall {outcome.recall_before:.2%}->{outcome.recall_after:.2%} " - f"fp {outcome.fp_before:.2%}->{outcome.fp_after:.2%} " - f"score {outcome.score_before}->{outcome.score_after} | edits: {edits}" - ) - if outcome.profile_regressions or outcome.profiles_under_minimum: - print(f" profiles: regressed={outcome.profile_regressions or '-'} under_minimum={outcome.profiles_under_minimum or '-'}") - print(f"rounds={len(outcomes)} accepted={len(accepted)}") - if dry_run: - print("dry_run=true (no ledger or mechanism store written)") - elif accepted: - print(f"ledger={ledger_path}") - else: - print("ledger=unchanged (no round accepted)") - - def _load_scan_findings(run_dir: Path) -> list[StaticFinding]: return load_findings(run_dir / "findings.json") @@ -1644,7 +1475,7 @@ def _plane_memory( assert run_manifest is not None seeded = memory.seed(run_manifest, store) if seeded: - print(f"memory {store.describe()}: seeded {len(seeded)} tasks (reused facts, loop snapshot): {', '.join(seeded)}") + print(f"memory {store.describe()}: seeded {len(seeded)} tasks (reused facts): {', '.join(seeded)}") return 0 annotations: dict[str, str] = {} if run_manifest is not None: @@ -1665,7 +1496,7 @@ def _plane_memory( def _plane_increment(args: argparse.Namespace, atespace: str) -> int: - from .plane.generate import LOOP_MANIFEST, Loop, increment + from .plane.generate import increment if args.candidates is None or args.triage is None: print("--validation-set needs --candidates and --triage", file=sys.stderr) @@ -1679,18 +1510,9 @@ def _plane_increment(args: argparse.Namespace, atespace: str) -> int: ) if args.runner_image is not None: command += f" --runner-image {str(args.runner_image).replace(home, '~', 1)}" - loop = None - if args.loop: - git = lambda *a: subprocess.run(["git", *a], capture_output=True, text=True, check=True).stdout.strip() # noqa: E731 - loop = Loop( - repo=args.loop_repo or git("remote", "get-url", "origin"), commit=args.loop_commit or git("rev-parse", "HEAD"), - manifest=args.loop_manifest or LOOP_MANIFEST, populations=tuple(args.qualify_population), - ) # fmt: skip - command += f" --loop --loop-commit {loop.commit} --loop-repo {loop.repo} --loop-manifest {loop.manifest}" - command += "".join(f" --qualify-population {p}" for p in loop.populations) written = increment( args.population, args.validation_set, args.candidates, args.triage, plane=args.plane, run_name=args.run, command=command, - repos=args.repos, runner_image=args.runner_image, loop=loop, atespace=atespace, + repos=args.repos, runner_image=args.runner_image, atespace=atespace, ) # fmt: skip print(f"{len(written)} manifests written under {args.plane} (Run {args.run})") return 0 diff --git a/src/openultrasast/improve/__init__.py b/src/openultrasast/improve/__init__.py deleted file mode 100644 index a5ba232..0000000 --- a/src/openultrasast/improve/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Bounded, gated self-improvement loop over the ruleset/policy governance data.""" - -from ..pairs import build_pair_signals -from .evolve import RoundOutcome, build_rule_signals, propose_status_edits, run_improvement, run_round -from .journal import load_journal, reverted_edit_keys -from .validator import ( - VALID_LEVERS, - EvolveBounds, - EvolveValidator, - PolicyConstantEdit, - RuleStatusEdit, - StrictValidationError, - edits_to_ledger, -) - -__all__ = [ - "EvolveBounds", - "EvolveValidator", - "PolicyConstantEdit", - "RoundOutcome", - "RuleStatusEdit", - "StrictValidationError", - "VALID_LEVERS", - "build_pair_signals", - "build_rule_signals", - "edits_to_ledger", - "load_journal", - "propose_status_edits", - "reverted_edit_keys", - "run_improvement", - "run_round", -] diff --git a/src/openultrasast/improve/evolve.py b/src/openultrasast/improve/evolve.py deleted file mode 100644 index aafdfc9..0000000 --- a/src/openultrasast/improve/evolve.py +++ /dev/null @@ -1,416 +0,0 @@ -"""Deterministic self-improvement loop (tasks 7.4, 7.5, 7.6). - -One round: benchmark with the current ledger -> per-rule signals -> propose bounded -edits (auto-shadow precision-draggers) -> validate -> replay smoke -> re-benchmark -> -hard acceptance gate (recall >= floor AND FP < ceiling AND project score not regressed -AND no matched-finding regression) -> accept (persist the ledger) or revert byte-for-byte. - -The edit *proposers* are the benchmark's per-rule signal and, opt-in, the plane's memory store -(:mod:`.memory`, ``run_round(proposals=...)``); both go through this same validator and gate. -The project score is the optimization reward; the recall/FP gate is a separate hard -feasibility constraint that is never folded into the reward. -""" - -from __future__ import annotations - -import tempfile -from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field -from pathlib import Path - -from ..benchmark import BenchmarkManifest, BenchmarkResult, BenchmarkRun, evaluate_benchmark -from ..findings import StaticFinding, quick_scan_findings -from ..mapping import analyze_entry_points, attach_reachability_hints -from ..policy import CwePolicy, load_policy -from ..preprocess import preprocess_repository -from ..rank import rank_targets -from ..ruleset import DEFAULT_RULESET_DIR, PatternRule, load_ruleset, read_rule_ledger, write_rule_ledger -from ..scoring import build_score_artifact -from .journal import append_round, load_journal, next_round_index, reverted_edit_keys -from .memory import MemoryProposal, applicable, proposal_id_of, write_sidecar -from .validator import EvolveValidator, RuleStatusEdit, StrictValidationError, edits_to_ledger - - -@dataclass(frozen=True) -class RoundOutcome: - round: int - accepted: bool - reason: str - edits: list[RuleStatusEdit] = field(default_factory=list) - recall_before: float = 0.0 - recall_after: float = 0.0 - fp_before: float = 0.0 - fp_after: float = 0.0 - score_before: int = 0 - score_after: int = 0 - matched_before: int = 0 - matched_after: int = 0 - profile_regressions: list[str] = field(default_factory=list) - profiles_under_minimum: list[str] = field(default_factory=list) - per_profile_before: dict[str, dict[str, float]] = field(default_factory=dict) - per_profile_after: dict[str, dict[str, float]] = field(default_factory=dict) - # learning-harness Req 5.2: the candidates this round declined to learn from, by pair name. - degradations: list[dict[str, object]] = field(default_factory=list) - - -MAX_PROFILES = 4 - - -def evaluate_profiles( - pair_cases: Sequence[object], - rules: tuple[PatternRule, ...], -) -> dict[str, dict[str, float]]: - """Pair-corpus metrics per provenance profile under ``rules`` (holdout split, no inventory sidecar). - - Only ``seeded`` and ``reviewed`` pairs gate (Req 9.3); ``advisory`` and ``title`` pairs are scored by ``pairs``, never here. - - Deliberately split-blind: this *measures* a ruleset, it never learns from a pair, so every pair is scored and the - teacher rule has nothing to say here. What the rule governs is the store these rules were built from - (`semantic.seed`, `semantic.loo`), and a filter here would only shrink the evidence (learning-harness Req 5.1). - """ - from ..pairs import GATING_TIERS, PairCase, evaluate_catalog, select_tier, select_vendored - - # Gates score vendored pairs only (Req 10.4); pointer pairs are for `pairs --pointers`, never for the improve gate. - cases = list(select_tier(select_vendored([case for case in pair_cases if isinstance(case, PairCase)]), GATING_TIERS)) - if not cases: - return {} - result = evaluate_catalog(cases, ruleset=rules, inventory_sidecar=False) - return { - profile: { - "pairs": float(metrics.pairs), - "pair_correct": float(metrics.pair_correct), - "pair_pass_rate": metrics.pair_pass_rate, - "youden": metrics.youden, - } - for profile, metrics in result.per_profile.items() - } - - -def profile_regressions( - before: dict[str, dict[str, float]], - after: dict[str, dict[str, float]], - *, - tolerance: float = 0.0, - min_pairs: int = 5, -) -> tuple[list[str], list[str]]: - """(regressed profiles, profiles under the minimum). - - A profile regresses when its pair pass rate or Youden drops by more than ``tolerance`` - (both are rates in [-1, 1], so one tolerance applies to both). - """ - regressed: list[str] = [] - small: list[str] = [] - for profile in sorted(set(before) | set(after)): - old = before.get(profile, {"pairs": 0.0, "pair_correct": 0.0, "pair_pass_rate": 0.0, "youden": 0.0}) - new = after.get(profile, old) - if old["pairs"] < min_pairs: - small.append(profile) - continue - old_rate = old.get("pair_pass_rate", old["pair_correct"] / old["pairs"] if old["pairs"] else 0.0) - new_rate = new.get("pair_pass_rate", new["pair_correct"] / new["pairs"] if new["pairs"] else 0.0) - if new_rate < old_rate - tolerance or new["youden"] < old["youden"] - tolerance: - regressed.append(profile) - return regressed, small - - -def build_rule_signals(result: BenchmarkResult) -> list[dict[str, object]]: - """Convert a benchmark result into per-rule loop signals (recall: miss; precision: fp).""" - signals: list[dict[str, object]] = [] - for miss in result.misses: - signals.append({"rule_id": miss.rule_id or "", "signal": "miss", "cwe": miss.cwe, "path": miss.path}) - for fp in result.false_positives: - signals.append({"rule_id": fp.rule_id, "signal": "fp", "path": fp.path, "line": fp.line}) - return sorted(signals, key=lambda item: (str(item.get("signal")), str(item.get("rule_id")), str(item.get("path")))) - - -def propose_status_edits( - per_rule: Mapping[str, dict[str, int]], - ruleset_by_id: Mapping[str, PatternRule], - current_ledger: Mapping[str, dict[str, object]], - blocked_keys: set[str], -) -> list[RuleStatusEdit]: - """Auto-shadow precision-draggers: enabled rules with false positives -> shadow (never delete).""" - edits: list[RuleStatusEdit] = [] - for rule_id, counts in sorted(per_rule.items()): - if counts.get("false_positives", 0) <= 0: - continue - rule = ruleset_by_id.get(rule_id) - if rule is None: - continue - current_status = str(current_ledger.get(rule_id, {}).get("status", rule.status)) - if current_status != "enabled": - continue # already staged or disabled - edit = RuleStatusEdit(rule_id=rule_id, from_status="enabled", to_status="shadow", rationale="") - if edit.key() in blocked_keys: - continue # novelty gate: previously reverted without a new rationale - edits.append(edit) - return edits - - -def run_round( - target: Path, - manifest: BenchmarkManifest, - *, - ledger_path: Path, - journal_path: Path, - ruleset_dir: Path = DEFAULT_RULESET_DIR, - policy: dict[str, CwePolicy] | None = None, - recall_floor: float = 0.9, - fp_ceiling: float = 0.1, - pair_cases: Sequence[object] | None = None, - profile_tolerance: float = 0.0, - min_holdout_pairs: int = 5, - proposals: Sequence[MemoryProposal] = (), -) -> RoundOutcome: - policy = policy if policy is not None else load_policy() - rounds = load_journal(journal_path) - round_index = next_round_index(rounds) - blocked = reverted_edit_keys(rounds) - current_ledger = read_rule_ledger(ledger_path) - - before_findings, before_rules = _scan(target, ruleset_dir, current_ledger, policy) - before = _evaluate(target, manifest, before_findings, before_rules, policy) - (ledger_path.parent / "rule_signals.json").parent.mkdir(parents=True, exist_ok=True) - _write_signals(ledger_path.parent / "rule_signals.json", build_rule_signals(before.result)) - - ruleset_by_id = {rule.rule_id: rule for rule in before_rules} - edits = propose_status_edits(before.result.metrics.per_rule, ruleset_by_id, current_ledger, blocked) - if proposals: - # harnessx-removal Req 6.2: memory proposals join after the benchmark's edits; the first edit per key wins. - taken = {edit.key() for edit in edits} - for proposal in applicable(proposals, ruleset_by_id, current_ledger, rounds): - if proposal.edit.key() not in taken: - taken.add(proposal.edit.key()) - edits.append(proposal.edit) - write_sidecar(journal_path, round_index, [p for p in proposals if p.edit in edits]) - refusals: list[dict[str, object]] = [] - if not edits: - return _outcome(round_index, accepted=False, reason="no_proposals", edits=[], before=before, after=before, degradations=refusals) - - validator = EvolveValidator() - try: - for edit in edits: - validator.validate(edit, ruleset_by_id, policy) - except StrictValidationError as exc: - _record(journal_path, round_index, edits, "rejected", before, before, str(exc)) - return _outcome( - round_index, accepted=False, reason=f"validation_failed: {exc}", edits=edits, before=before, after=before, degradations=refusals - ) - - candidate = edits_to_ledger(edits, current_ledger) - try: - _load_with(ruleset_dir, candidate) # replay smoke gate: candidate ruleset must boot - except Exception as exc: # noqa: BLE001 — any boot failure rejects the round - _record(journal_path, round_index, edits, "rejected", before, before, f"replay_failed: {exc}") - return _outcome( - round_index, accepted=False, reason="replay_failed", edits=edits, before=before, after=before, degradations=refusals - ) - - after_findings, after_rules = _scan(target, ruleset_dir, candidate, policy) - after = _evaluate(target, manifest, after_findings, after_rules, policy) - - accepted = ( - after.recall >= recall_floor and after.fp_rate < fp_ceiling and after.score >= before.score and after.matched >= before.matched - ) - profiles_before: dict[str, dict[str, float]] = {} - profiles_after: dict[str, dict[str, float]] = {} - regressed: list[str] = [] - small: list[str] = [] - if accepted and pair_cases: - # pair-corpus-honesty Req 7.2: a change may not help one provenance profile by hurting another. - profiles_before = evaluate_profiles(pair_cases, before_rules) - profiles_after = evaluate_profiles(pair_cases, after_rules) - if len(profiles_before) > MAX_PROFILES: - raise ValueError(f"at most {MAX_PROFILES} provenance profiles are supported; got {sorted(profiles_before)}") - regressed, small = profile_regressions(profiles_before, profiles_after, tolerance=profile_tolerance, min_pairs=min_holdout_pairs) - if regressed: - accepted = False - if accepted: - if edits: - write_rule_ledger(ledger_path, candidate) - reason = "accepted" - elif regressed: - reason = f"profile_regression:{regressed[0]}" # every regressed profile is listed in profile_regressions - else: - reason = "reverted" # ledger_path untouched -> byte-for-byte revert - _record( - journal_path, - round_index, - edits, - "accepted" if accepted else "reverted", - before, - after, - reason, - regressed, - small, - ) - outcome = _outcome(round_index, accepted=accepted, reason=reason, edits=edits, before=before, after=after, degradations=refusals) - return RoundOutcome( - **{ - **outcome.__dict__, - "profile_regressions": regressed, - "profiles_under_minimum": small, - "per_profile_before": profiles_before, - "per_profile_after": profiles_after, - } - ) - - -def run_improvement( - target: Path, - manifest: BenchmarkManifest, - *, - ledger_path: Path, - journal_path: Path, - ruleset_dir: Path = DEFAULT_RULESET_DIR, - policy: dict[str, CwePolicy] | None = None, - max_rounds: int = 5, - recall_floor: float = 0.9, - fp_ceiling: float = 0.1, - pair_cases: Sequence[object] | None = None, - profile_tolerance: float = 0.0, - min_holdout_pairs: int = 5, - proposals: Sequence[MemoryProposal] = (), -) -> list[RoundOutcome]: - """Run improvement rounds until convergence (no new proposals) or ``max_rounds``.""" - outcomes: list[RoundOutcome] = [] - for _ in range(max_rounds): - outcome = run_round( - target, - manifest, - ledger_path=ledger_path, - journal_path=journal_path, - ruleset_dir=ruleset_dir, - policy=policy, - recall_floor=recall_floor, - fp_ceiling=fp_ceiling, - pair_cases=pair_cases, - profile_tolerance=profile_tolerance, - min_holdout_pairs=min_holdout_pairs, - proposals=proposals, - ) - outcomes.append(outcome) - if outcome.reason == "no_proposals": - break - return outcomes - - -@dataclass(frozen=True) -class _Eval: - result: BenchmarkResult - recall: float - fp_rate: float - score: int - matched: int - - -def _scan( - target: Path, ruleset_dir: Path, ledger: Mapping[str, dict[str, object]], policy: dict[str, CwePolicy] -) -> tuple[list[StaticFinding], tuple[PatternRule, ...]]: - rules = _load_with(ruleset_dir, ledger) - _, targets = preprocess_repository(target) - targets = attach_reachability_hints(targets, analyze_entry_points(target, targets)) - rankings = rank_targets(targets) - findings = quick_scan_findings(target, targets, rankings, rules, policy) - reported = [finding for finding in findings if finding.status != "shadow"] - return reported, rules - - -def _evaluate( - target: Path, manifest: BenchmarkManifest, findings: list[StaticFinding], rules: tuple[PatternRule, ...], policy: dict[str, CwePolicy] -) -> _Eval: - run = BenchmarkRun(benchmark_run_id="improve", root=target, manifest=manifest) - result = evaluate_benchmark(run=run, mode="quick", findings=findings, scan_id=None, scan_run_dir=None) - metrics = result.metrics - cwe_by_rule = {rule.rule_id: rule.cwe for rule in rules} - rule_cwe_by_id = {f.finding_id: cwe_by_rule.get(f.finding_id.split(":", 1)[0], "") for f in findings} - score = build_score_artifact(findings, rule_cwe_by_id, policy).project_score - recall = metrics.recall if metrics.recall is not None else 1.0 - fp_rate = metrics.false_positive_findings_total / metrics.actual_findings_total if metrics.actual_findings_total else 0.0 - return _Eval(result=result, recall=recall, fp_rate=fp_rate, score=score, matched=metrics.matched_findings_total) - - -def _load_with(ruleset_dir: Path, ledger: Mapping[str, dict[str, object]]) -> tuple[PatternRule, ...]: - import json - - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: - handle.write(json.dumps(ledger)) - temp_path = Path(handle.name) - try: - return load_ruleset(ruleset_dir, temp_path) - finally: - temp_path.unlink(missing_ok=True) - - -def _write_signals(path: Path, signals: list[dict[str, object]]) -> None: - import json - - path.write_text(json.dumps(signals, indent=2, sort_keys=True) + "\n") - - -def _outcome( - round_index: int, - *, - accepted: bool, - reason: str, - edits: list[RuleStatusEdit], - before: _Eval, - after: _Eval, - degradations: list[dict[str, object]] | None = None, -) -> RoundOutcome: - return RoundOutcome( - round=round_index, - accepted=accepted, - reason=reason, - edits=edits, - degradations=list(degradations or []), - recall_before=before.recall, - recall_after=after.recall, - fp_before=before.fp_rate, - fp_after=after.fp_rate, - score_before=before.score, - score_after=after.score, - matched_before=before.matched, - matched_after=after.matched, - ) - - -def _record( - journal_path: Path, - round_index: int, - edits: list[RuleStatusEdit], - outcome: str, - before: _Eval, - after: _Eval, - reason: str = "", - profile_regressions: list[str] | None = None, - profiles_under_minimum: list[str] | None = None, -) -> None: - append_round( - journal_path, - { - "round": round_index, - "outcome": outcome, - "reason": reason or outcome, - "profile_regressions": list(profile_regressions or []), - "profiles_under_minimum": list(profiles_under_minimum or []), - "edits": [_journal_edit(e) for e in edits], - "recall_before": round(before.recall, 4), - "recall_after": round(after.recall, 4), - "fp_before": round(before.fp_rate, 4), - "fp_after": round(after.fp_rate, 4), - "score_before": before.score, - "score_after": after.score, - }, - ) - - -def _journal_edit(edit: RuleStatusEdit) -> dict[str, object]: - entry: dict[str, object] = { - "key": edit.key(), "lever": edit.lever, "rule_id": edit.rule_id, "from": edit.from_status, "to": edit.to_status, - "rationale": edit.rationale, - } # fmt: skip - proposal_id = proposal_id_of(edit) - if proposal_id is not None: - entry["evidence"] = proposal_id # only a memory edit carries it; every other entry is unchanged byte for byte - return entry diff --git a/src/openultrasast/improve/journal.py b/src/openultrasast/improve/journal.py deleted file mode 100644 index e866190..0000000 --- a/src/openultrasast/improve/journal.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Improvement journal: per-round audit + novelty memory (task 7.3, 7.9). - -Persistent JSON record of every self-improvement round so each rule/policy edit is -attributable and reversible, and so a previously-reverted edit is not re-proposed -without a new rationale (the novelty gate). -""" - -from __future__ import annotations - -import json -from pathlib import Path - - -def load_journal(path: Path) -> list[dict[str, object]]: - if not path.exists(): - return [] - payload = json.loads(path.read_text()) - return payload if isinstance(payload, list) else [] - - -def write_journal(path: Path, rounds: list[dict[str, object]]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(rounds, indent=2, sort_keys=True) + "\n") - - -def reverted_edit_keys(rounds: list[dict[str, object]]) -> set[str]: - """Edit keys from reverted rounds — blocked from re-proposal unless a new rationale appears.""" - keys: set[str] = set() - for entry in rounds: - if entry.get("outcome") != "reverted": - continue - for edit in _edits(entry): - key = edit.get("key") - # Rule edits are blocked only without a new rationale; mechanism edits are blocked outright (the loop - # always writes a rationale for them, and a reverted store edit must not be re-proposed every round). - if isinstance(key, str) and (not edit.get("rationale") or edit.get("lever") == "mechanisms"): - keys.add(key) - return keys - - -def next_round_index(rounds: list[dict[str, object]]) -> int: - return len(rounds) + 1 - - -def append_round(path: Path, entry: dict[str, object]) -> None: - rounds = load_journal(path) - rounds.append(entry) - write_journal(path, rounds) - - -def _edits(entry: dict[str, object]) -> list[dict[str, object]]: - edits = entry.get("edits") - return [edit for edit in edits if isinstance(edit, dict)] if isinstance(edits, list) else [] diff --git a/src/openultrasast/improve/memory.py b/src/openultrasast/improve/memory.py deleted file mode 100644 index 991e53f..0000000 --- a/src/openultrasast/improve/memory.py +++ /dev/null @@ -1,549 +0,0 @@ -"""Rule-status proposals from the plane's memory store (harnessx-removal Req 6.2/6.4, design section 5). - -The store (:mod:`..plane.memory`) holds what plane runs learned per repository and pin. Two deterministic rules turn -its ``alert`` and ``verdict`` rows into :class:`RuleStatusEdit` proposals that :func:`.evolve.run_round` validates -and gates exactly like the benchmark's own edits; a third reads dispute rates as advisory routing input only. - -Join key: an ``alert`` and a ``verdict`` match when repo, pin and ``path::function`` are equal. A ``disputed`` -final counts as no evidence in either direction. - -- **M1, repeated false alerts** (``enabled -> shadow``): at least :data:`M1_MIN_FALSE` alerts of the rule are false - -- at a ``rejected`` candidate, or on a ``fixed`` pin inside the declared fix range (the alert row says - ``in_fix_range: true``, or its ``path::function`` is a ``site_match`` site of the same repository) -- across at - least :data:`M1_MIN_REPOS` repositories, and no alert of the rule sits at an ``agreed`` or ``site_match`` - candidate. -- **M2, missed declared sites** (``shadow -> enabled``): the shadow rule's alerts hit at least :data:`M2_MIN_HITS` - ``agreed`` candidates with ``site_match`` true across at least :data:`M2_MIN_REPOS` repositories, no enabled rule - alerts at those candidates, the rule has no ``fixed``-pin alert at the same function, and at most - :data:`M2_MAX_REJECTED` alert at a ``rejected`` candidate. -- **M3, disputed families**: families with at least :data:`M3_MIN_CANDIDATES` candidates and a dispute rate of at - least :data:`M3_MIN_DISPUTE_RATE`. Advisory (``signals.json``); never an edit, never shown to the validator. - -**Coverage**: M1 and M2 skip every repository and pin whose ``coverage`` rows record the rule's language only as -``none`` (quick mode had no rule for it and no engine alerts were produced): a rule cannot be demoted or promoted -on evidence that could not exist. The skipped alerts are counted per ``repo@pin`` under ``coverage_none`` in the -proposal's ``excluded``. - -**Train-on-test guard** (:class:`Guard`), applied before any rule sees a row: a row is dropped when its population -is one the proposal will be qualified on, its (repo, pin) is a case of the gated benchmark manifest, or its -repository is a holdout pair's repository. The pair catalog records a holdout pair's *fix* commit, not the -vulnerable pin (its parent), so the holdout clause matches by repository, a superset of (repo, pin). This is the -rule the closed-loop leak broke, where a holdout pair taught the shape that then "recovered" it. - -**Provenance**: each proposal's rationale is ``memory:``, ``proposal_id`` being the sha256 of (rule, -edit key, sorted evidence row ids, thresholds). The sidecar ``memory_proposals.jsonl`` next to the journal carries -the evidence rows (id, repo, pin, run, task, object key and version -- the bucket's object version id, a file's content hash), -the counts that met each threshold, what the guard excluded and the store's index digest. -""" - -from __future__ import annotations - -import hashlib -import json -from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, field -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from ..plane.memory import MemoryStore, Record, repo_key, row_id, rows_digest -from ..ruleset import PatternRule -from .validator import RuleStatusEdit - -M1_MIN_FALSE = 3 -M1_MIN_REPOS = 2 -M2_MIN_HITS = 2 -M2_MIN_REPOS = 2 -M2_MAX_REJECTED = 1 -M3_MIN_CANDIDATES = 5 -M3_MIN_DISPUTE_RATE = 0.3 -THRESHOLDS: dict[str, dict[str, float]] = { - "M1": {"min_false_alerts": M1_MIN_FALSE, "min_repos": M1_MIN_REPOS, "max_agreed_hits": 0}, - "M2": {"min_site_hits": M2_MIN_HITS, "min_repos": M2_MIN_REPOS, "max_rejected": M2_MAX_REJECTED, "max_fixed_pin_same_function": 0}, - "M3": {"min_candidates": M3_MIN_CANDIDATES, "min_dispute_rate": M3_MIN_DISPUTE_RATE}, -} -RATIONALE_PREFIX = "memory:" -SIDECAR = "memory_proposals.jsonl" -SIGNALS = "signals.json" - - -@dataclass(frozen=True) -class MemoryProposal: - edit: RuleStatusEdit - provenance: dict[str, Any] - - @property - def proposal_id(self) -> str: - return str(self.provenance["proposal_id"]) - - -def proposal_id_of(edit: object) -> str | None: - """The proposal id a memory edit carries in its rationale; None for any other edit.""" - rationale = getattr(edit, "rationale", "") - return rationale[len(RATIONALE_PREFIX) :] if isinstance(rationale, str) and rationale.startswith(RATIONALE_PREFIX) else None - - -# --- the train-on-test guard --------------------------------------------------------------------------------------- - - -def _tail(repo: str) -> str: - """``owner/name`` of a repository, lower-cased: the pair catalog names repositories without a host.""" - return "/".join(repo_key(repo).lower().split("/")[-2:]) - - -@dataclass(frozen=True) -class Guard: - populations: frozenset[str] = frozenset() - gated_cases: frozenset[tuple[str, str]] = frozenset() # (repo_key, pin) of the benchmark being gated - holdout_repos: frozenset[str] = frozenset() # owner/name of every holdout pair - - @classmethod - def build( - cls, populations: Iterable[str] = (), gated_cases: Iterable[tuple[str, str]] = (), holdout_pairs: Iterable[object] = () - ) -> Guard: - repos = {_tail(str(getattr(p, "repo", ""))) for p in holdout_pairs if getattr(p, "repo", "")} - return cls(frozenset(populations), frozenset((repo_key(r), p) for r, p in gated_cases), frozenset(repos)) - - def reason(self, row: Mapping[str, Any]) -> str | None: - if row.get("population") in self.populations: - return "population" - if (row.get("repo"), row.get("pin")) in self.gated_cases: - return "gated_case" - if _tail(str(row.get("repo"))) in self.holdout_repos: - return "holdout_pair" - return None - - def apply(self, records: Iterable[Record]) -> tuple[list[Record], dict[str, Any]]: - """(kept records, what was excluded: row count, and counts per population / repo@pin per clause).""" - kept: list[Record] = [] - excluded: dict[str, Any] = {"rows": 0, "population": {}, "gated_case": {}, "holdout_pair": {}} - for record in records: - why = self.reason(record.row) - if why is None: - kept.append(record) - continue - excluded["rows"] += 1 - label = str(record.row.get("population")) if why == "population" else f"{record.row.get('repo')}@{record.row.get('pin')}" - excluded[why][label] = excluded[why].get(label, 0) + 1 - for clause in ("population", "gated_case", "holdout_pair"): - excluded[clause] = dict(sorted(excluded[clause].items())) - return kept, excluded - - -# --- the rules ----------------------------------------------------------------------------------------------------- - - -def _cand(row: Mapping[str, Any]) -> str: - return f"{row.get('path')}::{row.get('function')}" - - -def _status(rule_id: str, ruleset_by_id: Mapping[str, PatternRule], ledger: Mapping[str, dict[str, object]]) -> str | None: - rule = ruleset_by_id.get(rule_id) - return None if rule is None else str(ledger.get(rule_id, {}).get("status", rule.status)) - - -@dataclass -class _View: - verdicts: dict[tuple[str, str, str], Record] = field(default_factory=dict) # (repo, pin, candidate) - sites: set[tuple[str, str]] = field(default_factory=set) # (repo, candidate) with site_match true - alerts: dict[str, list[Record]] = field(default_factory=dict) # rule_id -> alerts - coverage: dict[tuple[str, str, str], set[str]] = field(default_factory=dict) # (repo, pin, language) -> recorded kinds - - @classmethod - def of(cls, records: Sequence[Record]) -> _View: - """One alert per (repo, pin, rule, path, line, function, pin role): every loop run re-alerts an unchanged pin under a - new run name, and a repeat is the same evidence, not more of it.""" - view = cls() - seen: set[tuple[object, ...]] = set() - for rec in records: - row = rec.row - if row["kind"] == "coverage" and row.get("language"): - view.coverage.setdefault((row["repo"], row["pin"], str(row["language"])), set()).add(str(row.get("coverage"))) - elif row["kind"] == "verdict": - view.verdicts[(row["repo"], row["pin"], str(row.get("candidate")))] = rec - if row.get("site_match") is True: - view.sites.add((row["repo"], str(row.get("candidate")))) - elif row["kind"] == "alert" and row.get("rule_id"): - place = (row["repo"], row["pin"], row["rule_id"], *(row.get(k) for k in ("path", "line", "function", "pin_role"))) - if place not in seen: - seen.add(place) - view.alerts.setdefault(str(row["rule_id"]), []).append(rec) - return view - - def verdict(self, alert: Mapping[str, Any]) -> Record | None: - return self.verdicts.get((alert["repo"], alert["pin"], _cand(alert))) - - def fixed_in_range(self, alert: Mapping[str, Any]) -> bool: - if alert.get("pin_role") != "fixed": - return False - flag = alert.get("in_fix_range") - return bool(flag) if flag is not None else (alert["repo"], _cand(alert)) in self.sites - - def blind(self, repo: str, pin: str, languages: Iterable[str]) -> bool: - """True when a language of the rule was recorded at this repository and pin with coverage ``none`` only: no - rule could fire there, so what the rule did or did not say there is no evidence either way.""" - return any(self.coverage.get((repo, pin, language)) == {"none"} for language in languages) - - def seeing(self, languages_by_rule: Mapping[str, Sequence[str]]) -> tuple[_View, dict[str, int]]: - """This view without the alerts at a repository and pin blind to their rule's language, and how many were - dropped per ``repo@pin``.""" - kept: dict[str, list[Record]] = {} - dropped: dict[str, int] = {} - for rule_id, alerts in self.alerts.items(): - languages = languages_by_rule.get(rule_id, ()) - for alert in alerts: - if self.blind(alert.row["repo"], alert.row["pin"], languages): - label = f"{alert.row['repo']}@{alert.row['pin']}" - dropped[label] = dropped.get(label, 0) + 1 - else: - kept.setdefault(rule_id, []).append(alert) - return _View(self.verdicts, self.sites, kept, self.coverage), dict(sorted(dropped.items())) - - -def _m1(rule_id: str, view: _View) -> tuple[list[Record], dict[str, int]] | None: - evidence: list[Record] = [] - repos: set[str] = set() - false_alerts = 0 - for alert in view.alerts.get(rule_id, []): - verdict = view.verdict(alert.row) - final = verdict.row.get("final") if verdict else None - if final == "agreed" or (verdict is not None and verdict.row.get("site_match") is True): - return None # the rule caught something the plane agreed on: never demote it on this evidence - if final == "rejected" or view.fixed_in_range(alert.row): - false_alerts += 1 - repos.add(alert.row["repo"]) - evidence.extend([alert, verdict] if verdict is not None and final == "rejected" else [alert]) - if false_alerts < M1_MIN_FALSE or len(repos) < M1_MIN_REPOS: - return None - return evidence, {"false_alerts": false_alerts, "repos": len(repos), "agreed_hits": 0} - - -def _m2(rule_id: str, view: _View, enabled: set[str]) -> tuple[list[Record], dict[str, int]] | None: - alerts = view.alerts.get(rule_id, []) - hits: dict[tuple[str, str, str], tuple[Record, Record]] = {} - rejected = 0 - for alert in alerts: - verdict = view.verdict(alert.row) - if verdict is None or alert.row.get("pin_role") == "fixed": - continue - if verdict.row.get("final") == "rejected": - rejected += 1 - elif verdict.row.get("final") == "agreed" and verdict.row.get("site_match") is True: - hits[(alert.row["repo"], alert.row["pin"], _cand(alert.row))] = (alert, verdict) - if not hits: - return None - caught = { - (a.row["repo"], a.row["pin"], _cand(a.row)) for rid in enabled if rid != rule_id for a in view.alerts.get(rid, []) - } # fmt: skip - missed = {k: v for k, v in hits.items() if k not in caught} - fixed_same = sum( - 1 for a in alerts if a.row.get("pin_role") == "fixed" and any((a.row["repo"], _cand(a.row)) == (r, c) for r, _, c in missed) - ) - repos = {repo for repo, _, _ in missed} - if len(missed) < M2_MIN_HITS or len(repos) < M2_MIN_REPOS or fixed_same > 0 or rejected > M2_MAX_REJECTED: - return None - evidence = [rec for key in sorted(missed) for rec in missed[key]] - return evidence, {"site_hits": len(missed), "repos": len(repos), "rejected": rejected, "fixed_pin_same_function": 0} - - -def _evidence(records: Sequence[Record]) -> list[dict[str, Any]]: - unique = {rec.row["id"]: rec for rec in records} - return [ - {"id": i, "kind": r.row["kind"], "repo": r.row["repo"], "pin": r.row["pin"], "run": r.row["run"], "task": r.row["task"], - "key": r.key, "version": r.version} - for i, r in sorted(unique.items()) - ] # fmt: skip - - -def evidence_digest(evidence_ids: Iterable[str]) -> str: - return hashlib.sha256("\n".join(sorted(evidence_ids)).encode("utf-8")).hexdigest() - - -def _proposal( - rule: str, edit_key: str, rule_id: str, records: Sequence[Record], counts: dict[str, int], excluded: dict[str, Any], index: str -) -> dict[str, Any]: - evidence = _evidence(records) - ids = [e["id"] for e in evidence] - seed = json.dumps([rule, edit_key, ids, THRESHOLDS[rule]], sort_keys=True, separators=(",", ":")) - return { - "proposal_id": hashlib.sha256(seed.encode("utf-8")).hexdigest(), - "rule": rule, - "edit_key": edit_key, - "rule_id": rule_id, - "evidence": evidence, - "evidence_digest": evidence_digest(ids), - "counts": counts, - "thresholds": THRESHOLDS[rule], - "excluded": excluded, - "index_digest": index, - } - - -def propose_from_memory( - rows: Sequence[Record], - ruleset_by_id: Mapping[str, PatternRule], - current_ledger: Mapping[str, dict[str, object]], - journal_rounds: Sequence[Mapping[str, object]], - *, - excluded: Guard, - index_digest: str = "", -) -> list[MemoryProposal]: - """M1 and M2 over the rows the guard keeps, minus proposals already reverted on the same evidence.""" - kept, dropped = excluded.apply(rows) - view, blind = _View.of(kept).seeing({rid: rule.languages for rid, rule in ruleset_by_id.items()}) - if blind: - dropped = {**dropped, "coverage_none": blind} - statuses = {rid: _status(rid, ruleset_by_id, current_ledger) for rid in ruleset_by_id} - enabled = {rid for rid, status in statuses.items() if status == "enabled"} - proposals: list[MemoryProposal] = [] - for rule_id in sorted(view.alerts): - status = statuses.get(rule_id) - if status == "enabled" and (m1 := _m1(rule_id, view)): - name, found, to_status = "M1", m1, "shadow" - elif status == "shadow" and (m2 := _m2(rule_id, view, enabled)): - name, found, to_status = "M2", m2, "enabled" - else: - continue # unknown to the ruleset (the loop cannot add rules), disabled, or no rule fired - key = RuleStatusEdit(rule_id, status, to_status).key() - provenance = _proposal(name, key, rule_id, found[0], found[1], dropped, index_digest) - edit = RuleStatusEdit(rule_id, status, to_status, rationale=RATIONALE_PREFIX + provenance["proposal_id"]) - proposals.append(MemoryProposal(edit, provenance)) - return applicable(proposals, ruleset_by_id, current_ledger, journal_rounds) - - -def applicable( - proposals: Sequence[MemoryProposal], - ruleset_by_id: Mapping[str, PatternRule], - current_ledger: Mapping[str, dict[str, object]], - journal_rounds: Sequence[Mapping[str, object]], -) -> list[MemoryProposal]: - """Proposals whose rule still has the ``from`` status and whose evidence no reverted round already tried.""" - tried: set[tuple[str, str]] = set() - for entry in journal_rounds: - edits = entry.get("edits") - if entry.get("outcome") == "reverted" and isinstance(edits, list): - tried |= {(str(e.get("key")), str(e.get("evidence"))) for e in edits if isinstance(e, dict) and e.get("evidence")} - return [ - p for p in proposals - if _status(p.edit.rule_id, ruleset_by_id, current_ledger) == p.edit.from_status and (p.edit.key(), p.proposal_id) not in tried - ] # fmt: skip - - -def disputed_families(rows: Sequence[Record], guard: Guard) -> list[dict[str, Any]]: - """M3: families disputed often enough to route differently. Advisory only: never an edit.""" - kept, _ = guard.apply(rows) - per: dict[str, list[int]] = {} - for rec in kept: - if rec.row["kind"] == "verdict" and rec.row.get("family"): - tally = per.setdefault(str(rec.row["family"]), [0, 0]) - tally[0] += 1 - tally[1] += rec.row.get("final") == "disputed" - return [ - {"family": fam, "candidates": n, "disputed": d, "dispute_rate": round(d / n, 4), "advice": "schedule pass c up front"} - for fam, (n, d) in sorted(per.items()) - if n >= M3_MIN_CANDIDATES and d / n >= M3_MIN_DISPUTE_RATE - ] - - -# --- the store, the sidecar and the outcomes ----------------------------------------------------------------------- - - -def index_digest(store: MemoryStore) -> str: - return rows_digest(store.index()) - - -def write_sidecar(journal_path: Path, round_index: int, proposals: Sequence[MemoryProposal]) -> None: - """Append each proposal once (by id) to ``memory_proposals.jsonl`` next to the journal.""" - if not proposals: - return - path = journal_path.with_name(SIDECAR) - seen = {json.loads(line)["proposal_id"] for line in path.read_text().splitlines() if line.strip()} if path.is_file() else set() - new = [p for p in proposals if p.proposal_id not in seen] - if not new: - return - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("a") as sink: - for p in new: - sink.write(json.dumps({**p.provenance, "round": round_index}, sort_keys=True, separators=(",", ":")) + "\n") - - -def read_sidecar(journal_path: Path) -> dict[str, dict[str, Any]]: - path = journal_path.with_name(SIDECAR) - if not path.is_file(): - return {} - return {row["proposal_id"]: row for row in (json.loads(line) for line in path.read_text().splitlines() if line.strip())} - - -def write_signals(directory: Path, signals: Sequence[Mapping[str, Any]]) -> Path: - path = directory / SIGNALS - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps({"advisory": True, "disputed_families": list(signals), "thresholds": THRESHOLDS["M3"]}, indent=2) + "\n") - return path - - -def outcome_of(reason: str, accepted: bool) -> str | None: - if accepted: - return "accepted" - if reason == "no_proposals": - return None - if reason.startswith(("validation_failed", "replay_failed")): - return "rejected" - return "reverted" - - -def outcome_rows( - edits: Sequence[RuleStatusEdit], - outcome: str, - round_index: int, - reason: str, - proposals: Mapping[str, MemoryProposal], - gate_run: str, -) -> list[dict[str, Any]]: - """One ``proposal_outcome`` row per (repository, pin) of each memory edit's evidence.""" - rows: list[dict[str, Any]] = [] - for edit in edits: - pid = proposal_id_of(edit) - proposal = proposals.get(pid or "") - if proposal is None: - continue - by_place: dict[tuple[str, str], dict[str, Any]] = {} - for ev in proposal.provenance["evidence"]: - by_place.setdefault((ev["repo"], ev["pin"]), ev) - for (repo, pin), _ in sorted(by_place.items()): - task = f"round-{round_index}" - rows.append( - { - "id": row_id("proposal_outcome", gate_run, task, f"{pid}:{repo}:{pin}"), "kind": "proposal_outcome", - "repo": repo, "pin": pin, "run": gate_run, "task": task, "population": "improve-loop", "split": "loop", - "image": "host", "edit_key": edit.key(), "proposal_id": pid, - "evidence_digest": proposal.provenance["evidence_digest"], "outcome": outcome, "reason": reason, - "gate_run": gate_run, - } - ) # fmt: skip - return rows - - -def gate_run_name(manifest_name: str) -> str: - return f"improve:{manifest_name}:{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}" - - -# --- the `ousast improve --memory` session ------------------------------------------------------------------------- - - -def benchmark_cases(manifest: object) -> frozenset[tuple[str, str]]: - """(repo, pin) of the gated benchmark's cases. Local sources (the only kind `resolve_benchmark_source` accepts) - name no repository pin, so they exclude nothing here; a pinned source would list its case.""" - source = getattr(manifest, "source", None) - repo, pin = getattr(source, "repo", ""), getattr(source, "pin", "") - return frozenset({(repo_key(repo), pin)}) if repo and pin else frozenset() - - -def store_spec(value: str) -> str | None: - """``--memory`` value -> ``open_store`` spec: empty uses OUSAST_MEMORY or the default, a bare path is a FileStore.""" - if not value: - return None - return value if "://" in value else f"file://{Path(value).resolve()}" - - -@dataclass -class MemorySession: - """One `improve --memory` invocation: the store read once, proposals per round, outcomes written back.""" - - store: MemoryStore - guard: Guard - records: list[Record] - ruleset_dir: Path - gate_run: str - made: dict[str, MemoryProposal] = field(default_factory=dict) - - @classmethod - def open( - cls, spec: str, manifest: object, target: Path, ruleset_dir: Path, populations: Sequence[str], catalog: Path | None - ) -> MemorySession: - from ..pairs import load_pair_catalog, select_split - from ..plane.memory import FileStore, open_store - - store = open_store(store_spec(spec)) - if isinstance(store, FileStore) and not (store.root / "index.jsonl").is_file(): - raise SystemExit(f"memory store {store.describe()} has no index.jsonl: an unread store would propose nothing") - if catalog is None or not catalog.is_file(): - raise SystemExit(f"--memory needs the pair catalog for the holdout guard: {catalog} is not a file") - holdout = select_split(load_pair_catalog(catalog), "holdout") - guard = Guard.build(populations, benchmark_cases(manifest), holdout) - records = store.rows() - print( - f"memory={store.describe()} index_entries={len(store.index())} rows={len(records)} " - f"guard: populations={sorted(guard.populations) or '-'} gated_cases={len(guard.gated_cases)} " - f"holdout_repos={len(guard.holdout_repos)}" - ) - return cls(store, guard, records, ruleset_dir, gate_run_name(str(getattr(manifest, "name", "manifest")))) - - def proposals(self, ledger_path: Path, journal_path: Path) -> list[MemoryProposal]: - from ..ruleset import load_ruleset, read_rule_ledger - from .journal import load_journal - - rules = load_ruleset(self.ruleset_dir, ledger_path if ledger_path.is_file() else None) - found = propose_from_memory( - self.records, - {r.rule_id: r for r in rules}, - read_rule_ledger(ledger_path), - load_journal(journal_path), - excluded=self.guard, - index_digest=index_digest(self.store), - ) - kept, excluded = self.guard.apply(self.records) - kinds: dict[str, int] = {} - for rec in kept: - kinds[rec.row["kind"]] = kinds.get(rec.row["kind"], 0) + 1 - print(f"memory_rows kept={len(kept)} by_kind={json.dumps(dict(sorted(kinds.items())))}") - print(f"memory_excluded={json.dumps(excluded, sort_keys=True)}") - for p in found: - print(f"memory_proposal {p.edit.key()} rule={p.provenance['rule']} id={p.proposal_id} evidence={len(p.provenance['evidence'])}") - print(f"memory_proposals={len(found)}") - self.made.update({p.proposal_id: p for p in found}) - return found - - def finish(self, outcomes: Sequence[Any], journal_path: Path, *, dry_run: bool) -> None: - signals = disputed_families(self.records, self.guard) - path = write_signals(journal_path.parent, signals) - print(f"memory_signals disputed_families={[s['family'] for s in signals] or '-'} ({path.name}, advisory)") - rows: list[dict[str, Any]] = [] - for o in outcomes: - outcome = outcome_of(o.reason, o.accepted) - if outcome is not None: - rows += outcome_rows(o.edits, outcome, o.round, o.reason, self.made, self.gate_run) - if dry_run: - print(f"memory_outcomes={len(rows)} (dry run: not written to the store)") - return - if rows: - self.store.put_rows(rows) - print(f"memory_outcomes={len(rows)} written to {self.store.describe()}") - - -__all__ = [ - "Guard", - "MemorySession", - "benchmark_cases", - "store_spec", - "M1_MIN_FALSE", - "M1_MIN_REPOS", - "M2_MAX_REJECTED", - "M2_MIN_HITS", - "M2_MIN_REPOS", - "M3_MIN_CANDIDATES", - "M3_MIN_DISPUTE_RATE", - "MemoryProposal", - "THRESHOLDS", - "applicable", - "disputed_families", - "evidence_digest", - "gate_run_name", - "index_digest", - "outcome_of", - "outcome_rows", - "propose_from_memory", - "proposal_id_of", - "read_sidecar", - "write_sidecar", - "write_signals", -] diff --git a/src/openultrasast/improve/validator.py b/src/openultrasast/improve/validator.py deleted file mode 100644 index 7440d27..0000000 --- a/src/openultrasast/improve/validator.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Bounded-lever edit model + validator for the self-improvement loop (tasks 7.1, 7.2). - -The loop may only adjust governance *data* through two bounded levers and never the -two authorities a human/upstream owns: detection **pattern text** and the 0-5 CWE -**severity**. Edits are typed so pattern/severity changes are structurally -impossible; the validator additionally enforces status/bounds/staging/resolution. -""" - -from __future__ import annotations - -import re -from collections.abc import Mapping -from dataclasses import dataclass - -from ..policy import CwePolicy -from ..ruleset import PatternRule -from ..ruleset.store import VALID_STATUS - -# The only levers the loop may pull (mirrors the design's journal._VALID_LEVERS). -VALID_LEVERS = frozenset({"rule", "policy", "mechanisms"}) -_IDENTIFIER = re.compile(r"^[A-Za-z_$][\w$]*$") -MECHANISM_ACTIONS = frozenset({"admit", "retract"}) -# Policy constants the loop may tune; the 0-5 severity is intentionally absent. -TUNABLE_POLICY_CONSTANTS = frozenset({"K", "MIN_SCORE"}) - - -class StrictValidationError(ValueError): - """Raised when a proposed loop edit violates the bounded-lever contract.""" - - def __init__(self, message: str, *, kind: str = "rule_change") -> None: - super().__init__(message) - self.kind = kind - - -@dataclass(frozen=True) -class RuleStatusEdit: - """A `rule`-lever edit: change a rule's status / evidence floor / precision estimate.""" - - rule_id: str - from_status: str - to_status: str - rationale: str = "" - lever: str = "rule" - - def key(self) -> str: - return f"rule:{self.rule_id}:{self.from_status}->{self.to_status}" - - -@dataclass(frozen=True) -class PolicyConstantEdit: - """A `policy`-lever edit: tune a score constant (K / MIN_SCORE). Never severity.""" - - name: str - from_value: float - to_value: float - rationale: str = "" - lever: str = "policy" - - def key(self) -> str: - return f"policy:{self.name}:{self.from_value}->{self.to_value}" - - -@dataclass(frozen=True) -class EvolveBounds: - k_range: tuple[float, float] = (20.0, 200.0) - min_score_range: tuple[int, int] = (0, 100) - - -class EvolveValidator: - """Enforces the bounded-lever safety contract; raises StrictValidationError on any breach.""" - - def __init__(self, bounds: EvolveBounds | None = None) -> None: - self._bounds = bounds or EvolveBounds() - - def validate(self, edit: object, ruleset_by_id: Mapping[str, PatternRule], policy: Mapping[str, CwePolicy]) -> None: - if isinstance(edit, RuleStatusEdit): - self._validate_rule(edit, ruleset_by_id, policy) - elif isinstance(edit, PolicyConstantEdit): - self._validate_policy(edit) - else: - raise StrictValidationError(f"unknown edit type {type(edit).__name__}", kind="rule_change") - - def _validate_rule(self, edit: RuleStatusEdit, ruleset_by_id: Mapping[str, PatternRule], policy: Mapping[str, CwePolicy]) -> None: - if edit.to_status not in VALID_STATUS: - raise StrictValidationError(f"invalid status {edit.to_status!r} for {edit.rule_id}") - rule = ruleset_by_id.get(edit.rule_id) - if rule is None: - raise StrictValidationError(f"edit targets unknown rule_id {edit.rule_id!r} (loop cannot add rules)") - if edit.from_status == "enabled" and edit.to_status == "disabled": - raise StrictValidationError(f"{edit.rule_id}: enabled->disabled jump is forbidden; stage via shadow first") - if rule.cwe not in policy: - raise StrictValidationError(f"{edit.rule_id}: CWE {rule.cwe} does not resolve in the policy") - - def _validate_policy(self, edit: PolicyConstantEdit) -> None: - if edit.name not in TUNABLE_POLICY_CONSTANTS: - raise StrictValidationError( - f"policy constant {edit.name!r} is not loop-tunable (severity is upstream-owned)", kind="policy_change" - ) - if edit.name == "K" and not (self._bounds.k_range[0] <= edit.to_value <= self._bounds.k_range[1]): - raise StrictValidationError(f"K={edit.to_value} out of bounds {self._bounds.k_range}", kind="policy_change") - if edit.name == "MIN_SCORE" and not (self._bounds.min_score_range[0] <= edit.to_value <= self._bounds.min_score_range[1]): - raise StrictValidationError(f"MIN_SCORE={edit.to_value} out of bounds {self._bounds.min_score_range}", kind="policy_change") - - -def edits_to_ledger(edits: list[RuleStatusEdit], base: Mapping[str, dict[str, object]]) -> dict[str, dict[str, object]]: - """Overlay rule-status edits onto a copy of the base ledger.""" - ledger: dict[str, dict[str, object]] = {key: dict(value) for key, value in base.items()} - for edit in edits: - entry = dict(ledger.get(edit.rule_id, {})) - entry["status"] = edit.to_status - ledger[edit.rule_id] = entry - return ledger - - -__all__ = [ - "EvolveBounds", - "EvolveValidator", - "MECHANISM_ACTIONS", - "PolicyConstantEdit", - "RuleStatusEdit", - "StrictValidationError", - "TUNABLE_POLICY_CONSTANTS", - "VALID_LEVERS", - "edits_to_ledger", -] diff --git a/src/openultrasast/pairs.py b/src/openultrasast/pairs.py index 1141228..23f41a0 100644 --- a/src/openultrasast/pairs.py +++ b/src/openultrasast/pairs.py @@ -1,8 +1,8 @@ """Vuln-vs-fixed pair corpus: TP on the vuln tree, silent on the fix tree. Isolated trees keep the two sides from contaminating each other (a `safe.py` -sitting next to `app.py` cannot measure false positives). Pair outcomes feed -the improvement loop as miss (vuln) and fp (fix) signals. +sitting next to `app.py` cannot measure false positives). Pair outcomes report +misses on vulnerable code and false positives on fixed code. pair-corpus-honesty: the overlay scorer counts ``coverage`` records with a source as detections and as leaks, matches by function when a label names one, @@ -61,7 +61,7 @@ FunctionRanges = dict[str, tuple[tuple[str, int, int], ...]] SLICE_NAMES = ("all", "local", "github", "sast", "vfc", "vibe-py", "vfc-js", "agent-vfc", "owasp") PROVENANCES = frozenset({"human", "agent", "mixed", "synthetic"}) SPLITS = frozenset({"train", "holdout"}) -# Req 9: how a label was established. Only seeded and reviewed pairs steer the improve loop. +# Req 9: how a label was established. REVIEW_TIERS = frozenset({"seeded", "advisory", "title", "reviewed"}) GATING_TIERS = frozenset({"seeded", "reviewed"}) # Req 10: pointer pairs are harvested into a cache outside the repository, never vendored. @@ -442,7 +442,7 @@ def evaluate_catalog( inventory_sidecar: bool = True, pointers: bool | None = None, ) -> PairEvalResult: - """Score every case. ``ruleset`` lets the improve loop score a candidate ledger; ``inventory_sidecar=False`` skips the second scan. + """Score every case. ``ruleset`` selects the rules to score; ``inventory_sidecar=False`` skips the second scan. Pointer pairs (``vendored = false``) are harvested into the cache when ``pointers`` (or OPENULTRASAST_PAIRS_NETWORK=1) allows the network, otherwise skipped with a ``pointer_pair_skipped`` degradation (Req 10.2, 10.3). @@ -497,14 +497,14 @@ def evaluate_catalog( def build_pair_signals(outcomes: Sequence[PairOutcome], *, split: str | None = None) -> list[dict[str, object]]: - """Same miss/fp vocabulary as ``build_rule_signals``, tagged with the pair name, profile, and mechanisms. + """Miss and false-positive signals, tagged with the pair name, profile, and mechanisms. ``split`` restricts the signals to one split, so a learning path teaches from the train rows only (Req 5.1). """ signals: list[dict[str, object]] = [] for outcome in outcomes: if outcome.known_limit is not None: - continue # unachievable by construction; the loop must not chase it + continue # excluded from the achievable metrics if split is not None and outcome.split != split: continue common: dict[str, object] = { @@ -653,7 +653,7 @@ def select_slice(cases: Sequence[PairCase], slice_name: str | None) -> tuple[Pai def select_tier(cases: Sequence[PairCase], tiers: Iterable[str]) -> tuple[PairCase, ...]: - """Pairs whose review tier is in ``tiers`` (the improve loop passes GATING_TIERS).""" + """Pairs whose review tier is in ``tiers``.""" wanted = frozenset(tiers) return tuple(case for case in cases if case.review_tier in wanted) diff --git a/src/openultrasast/plane/generate.py b/src/openultrasast/plane/generate.py index 47c6e0e..8c5888e 100644 --- a/src/openultrasast/plane/generate.py +++ b/src/openultrasast/plane/generate.py @@ -20,13 +20,6 @@ under ``--plane`` (default ``plane/``), everything a Run over the set needs besi with budgets, annotated with the population and split (``openultrasast.io/population``/``split``: the files' stems) the memory rows carry. -``--loop`` (harnessx-removal design section 6) appends the improvement loop: per case ``alerts`` (quick-mode rules -on the vulnerable and the fixed pin, which gets its Workspace ``-fixed``; ``case.json`` then also carries the -fix's new-side ranges) feeding ``remember``, and once ``memory-snapshot`` (the store's rows after the train-on-test -guard, written on the host by ``memory.seed``) -> ``loop-measure`` -> ``loop-propose`` -> ``loop-improve``, the last -two bound to this repository at the commit under test. All of them model-free with budget ``{usd: 0, calls: 0}``; -the gate's verdict is ``/loop-improve/gate.json``, and adopting an accepted ledger stays a maintainer commit. - Each ``repo-facts`` Task carries ``openultrasast.io/memory-key`` (repository, pin, candidates digest, runner image digest): the key under which stored facts are reused instead of recomputed (``plane/memory.py``, ``seed``). @@ -67,12 +60,12 @@ import yaml from ..model.endpoint import price_of from .budget import cost_of from .manifests import GIT_COMMITS_ANNOTATION, RUN_API_VERSION, Task, load_manifests -from .memory import MEMORY_KEY_ANNOTATION, POPULATION_ANNOTATION, SNAPSHOT_ANNOTATION, SPLIT_ANNOTATION, memory_key +from .memory import MEMORY_KEY_ANNOTATION, POPULATION_ANNOTATION, SPLIT_ANNOTATION, memory_key from .reconciler import _ax_name, _doc from .tasks.repo_facts import _digest from .tasks.verify import MAX_STEPS, units_of -__all__ = ["CaseInputs", "Loop", "fix_ranges", "increment", "read_cases", "render", "repin_templates"] +__all__ = ["CaseInputs", "fix_ranges", "increment", "read_cases", "render", "repin_templates"] REFERENCE_USD_PER_CANDIDATE = 0.022 # two passes: verifier-batched-check-2026-09-29.json cost.usd_per_candidate_two_passes REFERENCE_MODEL = "deepseek-flash" # verify_batched.py's default (DEFAULT_DETECTOR_MODEL) in the batched check @@ -86,12 +79,7 @@ CALLS_PER_HUNT = MAX_STEPS + 2 # every tool step, the answer, one empty-content ATESPACE = "default" # the default when no profile is given; ``increment`` takes ``profile.atespace`` CASE_PATH = "/workspace/case" INPUTS_PATH = "/workspace/inputs" -FIXED_PATH = "/workspace/fixed" -PROJECT_PATH = "/workspace/project" -TEMPLATES = ("repo-facts", "verify", "agree", "remember", "alerts", "loop", "features", "roles") -FIXED_RANGES_BASIS = "git diff -U0 , new side: the fix's lines at the fixed pin" -LOOP_MANIFEST = "benchmarks/manifests/java-spring-boot-vulnerable.toml" # the equality baseline's `improve` manifest -LOOP_CATALOG = "benchmarks/pairs/catalog.toml" +TEMPLATES = ("repo-facts", "verify", "agree", "remember", "alerts", "features", "roles") Candidate = tuple[str, str, int] @@ -105,20 +93,6 @@ class CaseInputs: recorded_usd: float triage_usd: float = 0.0 ranges: Mapping[str, Sequence[tuple[int, int]]] | None = None - fixed_ranges: Mapping[str, Sequence[tuple[int, int]]] | None = None - - -@dataclass(frozen=True) -class Loop: - """The improvement loop appended to a Run (``--loop``, design section 6): ``repo`` at ``commit`` is this - repository at the commit under test, where ``manifest`` and ``catalog`` (paths inside it) gate the proposals; - rows of ``populations`` never propose (the train-on-test guard).""" - - repo: str - commit: str - manifest: str = LOOP_MANIFEST - catalog: str = LOOP_CATALOG - populations: tuple[str, ...] = () def fix_ranges(repo: Path, vulnerable: str, fixed: str, side: str = "old") -> dict[str, list[tuple[int, int]]]: @@ -165,11 +139,10 @@ def _scan_rows(triage_dir: Path, case_id: str) -> list[dict[str, Any]]: def read_cases( - validation_set: Path, candidates_dir: Path, triage_dir: Path, population: Path, repos: Path | None = None, *, fixed_side: bool = False + validation_set: Path, candidates_dir: Path, triage_dir: Path, population: Path, repos: Path | None = None ) -> list[CaseInputs]: """The set's cases in id order, each with its candidates, the triage-kept subset, the recorded cost and its - triage share, and -- with ``repos`` (one clone per case id) -- the fix's old-side ranges (and, with - ``fixed_side``, its new-side ranges, which the loop's `alerts` task marks fixed-pin alerts by).""" + triage share, and -- with ``repos`` (one clone per case id) -- the fix's old-side ranges.""" subset: dict[str, list[list[str]]] = json.loads(validation_set.read_text(encoding="utf-8")) records = {c["id"]: c for c in tomllib.loads(population.read_text(encoding="utf-8"))["case"]} cases: list[CaseInputs] = [] @@ -199,11 +172,6 @@ def read_cases( recorded_usd=round(sum(float(r.get("usd") or 0.0) for r in rows), 6), triage_usd=_triage_usd(rows), ranges=None if repos is None else fix_ranges(repos / case_id, records[case_id]["vulnerable"], records[case_id]["fixed"]), - fixed_ranges=( - fix_ranges(repos / case_id, records[case_id]["vulnerable"], records[case_id]["fixed"], side="new") - if repos is not None and fixed_side - else None - ), ) ) return cases @@ -226,9 +194,6 @@ def _case_record(item: CaseInputs) -> dict[str, Any]: if item.ranges is not None: record["ranges"] = {path: [list(span) for span in spans] for path, spans in item.ranges.items()} record["ranges_basis"] = RANGES_BASIS - if item.fixed_ranges is not None: - record["fixed_ranges"] = {path: [list(span) for span in spans] for path, spans in item.fixed_ranges.items()} - record["fixed_ranges_basis"] = FIXED_RANGES_BASIS record["sites_in_set"] = [s for s in sites if s in in_set] record["cost"] = { "candidates_before_triage": len(item.candidates), "recorded_usd": item.recorded_usd, @@ -304,95 +269,38 @@ def _free() -> dict[str, int]: return {"usd": 0, "calls": 0} # model-free: budget.py refuses any call, so a stray one fails loudly -def _chain_inputs(case_id: str, alerts: bool) -> dict[str, str]: - """Every artifact of a case's chain -- facts, the three passes with their summaries, the final agree -- and, in a - loop, its alerts with their coverage.""" +def _chain_inputs(case_id: str) -> dict[str, str]: + """Every artifact of a case's chain: facts, the three passes with their summaries, and the final agreement.""" inputs = {"facts": f"{case_id}-facts/facts.json", "facts_summary": f"{case_id}-facts/summary.json"} for label in ("a", "b", "c"): inputs.update({f"pass_{label}": f"{case_id}-v{label}/units.jsonl", f"summary_{label}": f"{case_id}-v{label}/summary.json"}) inputs["agreed"] = f"{case_id}-final/agreed.json" - if alerts: - inputs["alerts"] = f"{case_id}-alerts/alerts.jsonl" - inputs["alerts_summary"] = f"{case_id}-alerts/summary.json" # the coverage: whether a zero could mean clean return inputs -def _features(item: CaseInputs, templates: Mapping[str, Task], alerts: bool) -> tuple[dict, dict]: +def _features(item: CaseInputs, templates: Mapping[str, Task]) -> tuple[dict, dict]: """(Task, Run entry) of the case's `features` (learned-decision-engine design section 1): model-free, bound to the vulnerable Workspace for the functions' spans and entry-point distance; the chain's artifacts as input.""" case_id = item.case["id"] env = {"OUSAST_WORKSPACE_DIR": f"{CASE_PATH}/repo"} task = _task(templates["features"], f"features-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)]) - inputs = _chain_inputs(case_id, alerts) + inputs = _chain_inputs(case_id) entry = {"name": f"{case_id}-features", "task": f"features-{case_id}", "inputs": inputs, "outputs": ["features.jsonl", "summary.json"]} return task, {**entry, "budget": _free()} -def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, split: str, alerts: bool) -> tuple[dict, dict]: +def _remember(item: CaseInputs, templates: Mapping[str, Task], population: str, split: str) -> tuple[dict, dict]: """(Task, Run entry) of the case's `remember` (design section 4): bound to the vulnerable Workspace for the - repository and pin its rows carry; every artifact of the case's chain (and, in a loop, its alerts) and the case's + repository and pin its rows carry; every artifact of the case's chain and the case's feature records as input.""" case_id = item.case["id"] env = {"OUSAST_POPULATION": population or "unknown", "OUSAST_SPLIT": split or "unknown"} task = _task(templates["remember"], f"remember-{case_id}", env, [(_ax_name(case_id, "vulnerable"), CASE_PATH)]) - inputs = {**_chain_inputs(case_id, alerts), "features": f"{case_id}-features/features.jsonl"} + inputs = {**_chain_inputs(case_id), "features": f"{case_id}-features/features.jsonl"} entry = {"name": f"{case_id}-remember", "task": f"remember-{case_id}", "inputs": inputs, "outputs": ["memory.jsonl"], "budget": _free()} return task, entry -def _alerts(item: CaseInputs, templates: Mapping[str, Task], atespace: str = ATESPACE) -> tuple[dict, dict, dict]: - """(fixed-pin Workspace, Task, Run entry) of the case's `alerts`: quick-mode rules on both pins.""" - case = item.case - git = [{"name": "repo", "repo": case["repo"], "dir": "repo", "depth": 1}] - fixed = _doc("Workspace", _ax_name(case["id"], "fixed"), {"git": git}, atespace) - fixed["metadata"]["annotations"] = {GIT_COMMITS_ANNOTATION: f"repo={case['fixed']}"} - bindings = [(_ax_name(case["id"], "vulnerable"), CASE_PATH), (fixed["metadata"]["name"], FIXED_PATH)] - bindings.append((_ax_name(case["id"], "inputs"), INPUTS_PATH)) - env = { - "OUSAST_WORKSPACE_DIR": f"{CASE_PATH}/repo", "OUSAST_FIXED_DIR": f"{FIXED_PATH}/repo", - "OUSAST_INPUT_CASE": f"{INPUTS_PATH}/case.json", "OUSAST_VULNERABLE_PIN": str(case["vulnerable"]), - "OUSAST_FIXED_PIN": str(case["fixed"]), - } # fmt: skip - task = _task(templates["alerts"], f"alerts-{case['id']}", env, bindings) - entry = {"name": f"{case['id']}-alerts", "task": f"alerts-{case['id']}", "outputs": ["alerts.jsonl", "summary.json"], "budget": _free()} - entry["serialize"] = "engine" - return fixed, task, entry - - -def _loop( - cases: Sequence[CaseInputs], templates: Mapping[str, Task], loop: Loop, atespace: str = ATESPACE -) -> tuple[dict, list[dict], list[dict]]: - """(project Workspace, Tasks, Run entries) of the loop's singletons: snapshot -> measure -> propose -> improve.""" - git = [{"name": "repo", "repo": loop.repo, "dir": "repo", "depth": 1}] - project = _doc("Workspace", _ax_name("openultrasast", loop.commit[:12]), {"git": git}, atespace) - project["metadata"]["annotations"] = {GIT_COMMITS_ANNOTATION: f"repo={loop.commit}"} - bound = [(project["metadata"]["name"], PROJECT_PATH)] - env = { - "OUSAST_PROJECT_DIR": f"{PROJECT_PATH}/repo", "OUSAST_LOOP_MANIFEST": loop.manifest, "OUSAST_LOOP_CATALOG": loop.catalog, - "OUSAST_QUALIFY_POPULATIONS": json.dumps(sorted(loop.populations)), - } # fmt: skip - snapshot = _task(templates["loop"], "memory-snapshot", {}, [], ["snapshot"]) - guard = {"catalog": loop.catalog, "manifest": loop.manifest, "populations": sorted(loop.populations)} - snapshot["metadata"].setdefault("annotations", {})[SNAPSHOT_ANNOTATION] = json.dumps(guard, sort_keys=True, separators=(",", ":")) - tasks = [ - snapshot, _task(templates["loop"], "loop-measure", {}, [], ["measure"]), - _task(templates["loop"], "loop-propose", env, bound, ["propose"]), - _task(templates["loop"], "loop-improve", env, bound, ["improve"]), - ] # fmt: skip - remembered = {f"remember_{c.case['id']}": f"{c.case['id']}-remember/memory.jsonl" for c in cases} - entries = [ - {"name": "memory-snapshot", "task": "memory-snapshot", "outputs": ["rows.jsonl", "index.json"], "budget": _free()}, - {"name": "loop-measure", "task": "loop-measure", "inputs": {**remembered, "snapshot": "memory-snapshot/rows.jsonl"}, - "outputs": ["measure.json", "rows.jsonl"], "budget": _free()}, - {"name": "loop-propose", "task": "loop-propose", - "inputs": {"measure": "loop-measure/measure.json", "rows": "loop-measure/rows.jsonl", "index": "memory-snapshot/index.json"}, - "outputs": ["proposals.jsonl", "memory_proposals.jsonl", "signals.json"], "budget": _free()}, - {"name": "loop-improve", "task": "loop-improve", "inputs": {"proposals": "loop-propose/proposals.jsonl"}, - "outputs": ["gate.json", "journal.json"], "budget": _free(), "serialize": "engine"}, - ] # fmt: skip - return project, tasks, entries - - def render( cases: Sequence[CaseInputs], templates: Mapping[str, Task], @@ -401,13 +309,11 @@ def render( *, population: str = "", split: str = "", - loop: Loop | None = None, atespace: str = ATESPACE, ) -> dict[str, str]: """Relative path under the plane root -> file text, for every generated manifest. ``population`` and ``split`` (the population file's and the set's stems) become the Run's annotations, which the memory rows carry. Each - case ends in `features` -> `remember`; ``loop`` adds per case `alerts` (and the fixed-pin Workspace), and once the - snapshot, `loop-measure`, `loop-propose` and `loop-improve` (design section 6), all model-free.""" + case ends in `features` -> `remember`.""" header = f"# generated by `{command}`; do not edit, regenerate.\n" out: dict[str, str] = {} tasks: list[dict[str, Any]] = [] @@ -454,22 +360,12 @@ def render( tasks.sort(key=lambda doc: 1 if doc["metadata"]["name"].startswith("verify-c-") else 0) # stable: the old Tasks first entries.extend(tiebreak) # appended: the first Run's entries stay byte-identical, so a rerun keeps their state for item in cases: # appended after the tie-break for the same reason - if loop is not None: - fixed, alerts_task, alerts_entry = _alerts(item, templates, atespace) - out[f"workspaces/{fixed['metadata']['name']}.yaml"] = _dump(header, [fixed]) - tasks.append(alerts_task) - entries.append(alerts_entry) - features_task, features_entry = _features(item, templates, loop is not None) + features_task, features_entry = _features(item, templates) tasks.append(features_task) entries.append(features_entry) - remember_task, remember_entry = _remember(item, templates, population, split, loop is not None) + remember_task, remember_entry = _remember(item, templates, population, split) tasks.append(remember_task) entries.append(remember_entry) - if loop is not None: - project, loop_tasks, loop_entries = _loop(cases, templates, loop, atespace) - out[f"workspaces/{project['metadata']['name']}.yaml"] = _dump(header, [project]) - tasks.extend(loop_tasks) - entries.extend(loop_entries) out[f"tasks/{run_name}.yaml"] = _dump(header, tasks) annotations = {k: v for k, v in ((POPULATION_ANNOTATION, population), (SPLIT_ANNOTATION, split)) if v} metadata: dict[str, Any] = {"name": run_name, **({"annotations": annotations} if annotations else {})} @@ -519,24 +415,21 @@ def increment( command: str, repos: Path | None = None, runner_image: Path | None = None, - loop: Loop | None = None, atespace: str | None = None, ) -> list[Path]: """Write the generated manifests under ``plane`` from the templates in ``plane/tasks`` (re-pinned first when - ``runner_image`` is given), with the improvement loop when ``loop`` is given; returns the paths. Workspaces go + ``runner_image`` is given); returns the paths. Workspaces go to ``atespace`` (default: the profile's).""" if atespace is None: from .profile import load_profile atespace = load_profile().atespace - if loop is not None and repos is None: - raise ValueError("--loop needs --repos: the alerts task marks fixed-pin alerts by the fix's new-side ranges") if runner_image is not None: repin_templates(plane, runner_image) loaded = load_manifests([plane / "tasks" / f"{name}.yaml" for name in TEMPLATES]) - cases = read_cases(validation_set, candidates_dir, triage_dir, population, repos, fixed_side=loop is not None) + cases = read_cases(validation_set, candidates_dir, triage_dir, population, repos) files = render( - cases, loaded.tasks, run_name, command, population=population.stem, split=validation_set.stem, loop=loop, atespace=atespace + cases, loaded.tasks, run_name, command, population=population.stem, split=validation_set.stem, atespace=atespace ) written: list[Path] = [] for relative, text in files.items(): diff --git a/src/openultrasast/plane/memory.py b/src/openultrasast/plane/memory.py index 9ddebef..b17b8ba 100644 --- a/src/openultrasast/plane/memory.py +++ b/src/openultrasast/plane/memory.py @@ -2,7 +2,7 @@ One store, keyed by repository and pin, holds what plane runs learned: repository facts by content hash, and rows of the kinds in ``KINDS`` (``facts``, ``verdict``, ``unit_cost``, ``alert``, ``coverage``, ``features``, ...) that -``remember`` (:mod:`.tasks.remember`) derives from a run's delivered artifacts, plus the loop's and the decision +``remember`` (:mod:`.tasks.remember`) derives from a run's delivered artifacts, plus the decision engine's own. Layout, identical for both backends:: index.jsonl one row per ingested (run, task): sha256 of its rows, row count @@ -34,10 +34,6 @@ Fact reuse: a facts entry is valid for (repo, pin, candidates digest, runner ima writes as the Task annotation ``openultrasast.io/memory-key``. :func:`seed` runs before a Run: for every entry whose Task carries a key with stored facts and no state yet, it writes ``//facts.json`` and a ``done`` summary and marks the task done under the run lock (``reconciler.mark_done``), so the reconciler skips it. - -The loop's ``memory-snapshot`` (a Task annotated ``openultrasast.io/memory-snapshot`` with the train-on-test guard's -parameters) is seeded the same way: a sandboxed task cannot read this store, so :func:`seed` writes the store's rows -after the guard into ``/memory-snapshot/`` (:func:`.tasks.loop.write_snapshot`) and marks it done. """ from __future__ import annotations @@ -62,7 +58,6 @@ log = logging.getLogger(__name__) MEMORY_KEY_ANNOTATION = "openultrasast.io/memory-key" POPULATION_ANNOTATION = "openultrasast.io/population" SPLIT_ANNOTATION = "openultrasast.io/split" -SNAPSHOT_ANNOTATION = "openultrasast.io/memory-snapshot" # the loop's guard: {"catalog", "manifest", "populations"} # The decision engine's kinds (learned-decision-engine, Data Models): `features` from the `features` task through # `remember`; `label`, `decision`, `experiment`, `arm_outcome` and `experiment_result` for the later tasks; `example` # (a labelled case of the local memory: label + feature record + excerpt sha) from `ousast learn memory build`. @@ -984,8 +979,7 @@ def ingest(run_dir: Path, store: MemoryStore | None = None) -> list[IngestResult def seed(run_manifest: Path, store: MemoryStore | None = None, *, results_root: Path | None = None) -> list[str]: """Reuse stored facts for a Run's tasks whose ``openultrasast.io/memory-key`` matches (Req 6.1): each such task without a directory or a state gets ``facts.json`` and a ``done`` summary and is marked done under the run - lock. Returns the seeded task names. A changed pin, candidate set or runner image finds nothing and recomputes. - A ``memory-snapshot`` Task gets the store's rows after the guard its annotation names, marked done likewise.""" + lock. Returns the seeded task names. A changed pin, candidate set or runner image finds nothing and recomputes.""" from . import reconciler run_spec, manifests = reconciler.load_run(Path(run_manifest)) @@ -995,16 +989,8 @@ def seed(run_manifest: Path, store: MemoryStore | None = None, *, results_root: seeded: list[str] = [] for entry in run_spec.tasks: annotations = manifests.tasks[entry.task].metadata.annotations - text, snapshot = annotations.get(MEMORY_KEY_ANNOTATION), annotations.get(SNAPSHOT_ANNOTATION) - if not (text or snapshot) or (base / entry.name).exists() or state.get(entry.name, {}).get("status", "pending") != "pending": - continue - if snapshot: - from .tasks.loop import write_snapshot # lazy: the guard reads the pair catalog and the benchmark manifest - - info = write_snapshot(base / entry.name, store, json.loads(snapshot)) - reused = {"snapshot": store.describe(), **{k: info[k] for k in ("rows", "kept", "index_digest")}} - reconciler.mark_done(run_spec.metadata.name, entry.name, reused=reused, results_root=results_root) - seeded.append(entry.name) + text = annotations.get(MEMORY_KEY_ANNOTATION) + if not text or (base / entry.name).exists() or state.get(entry.name, {}).get("status", "pending") != "pending": continue key = parse_memory_key(str(text)) where = {"candidates_digest": key["candidates"], "image": key["image"]} @@ -1037,7 +1023,6 @@ __all__ = [ "POPULATION_ANNOTATION", "QUERYABLE", "QUERY_FIELDS", - "SNAPSHOT_ANNOTATION", "SPLIT_ANNOTATION", "FileStore", "IngestResult", diff --git a/src/openultrasast/plane/tasks/alerts.py b/src/openultrasast/plane/tasks/alerts.py index 5c31044..5af6923 100644 --- a/src/openultrasast/plane/tasks/alerts.py +++ b/src/openultrasast/plane/tasks/alerts.py @@ -1,6 +1,6 @@ """`alerts` (harnessx-removal Requirement 6.3, design section 6): what the deterministic rules say about a case. -No model, budget ``{usd: 0, calls: 0}``, one per case in a ``--loop`` Run, serialized like ``repo-facts`` (a full-tree +No model, budget ``{usd: 0, calls: 0}``, serialized like ``repo-facts`` (a full-tree scan per pin). It runs the quick-mode scan -- ``preprocess_repository``, the entry-point reachability hints, ``rank_targets`` and ``quick_scan_findings`` over the image's bundled ruleset, enabled and shadow rules alike, exactly what ``ousast scan --mode quick`` runs -- on the case's vulnerable pin and on its fixed pin, and writes one row per @@ -12,8 +12,7 @@ what ``ousast scan --mode quick`` runs -- on the case's vulnerable pin and on it candidates' ``path::function``; ```` outside any function); a language those patterns do not cover keeps the scan's own reachability attribution. ``in_fix_range`` says whether the line lies inside the fix: the old-side ranges of ``case.json`` on the vulnerable pin, the new-side ``fixed_ranges`` on the fixed pin (null when ``case.json`` has -none). A fixed-pin alert inside the fix is a false alert on code the fix wrote: rule M1 of ``improve/memory.py`` -counts it. +none). A fixed-pin alert inside the fix is a false alert on code the fix wrote. **Coverage** (an honest zero): quick mode has rules only for the languages its ruleset declares (:func:`quick_languages`, derived from the bundled ruleset directory, never listed by hand). ``summary.json`` records diff --git a/src/openultrasast/plane/tasks/loop.py b/src/openultrasast/plane/tasks/loop.py deleted file mode 100644 index 8ea364c..0000000 --- a/src/openultrasast/plane/tasks/loop.py +++ /dev/null @@ -1,302 +0,0 @@ -"""The improvement loop as plane tasks (harnessx-removal Requirement 6.3, design section 6). - -``command: ["loop", ]``; every step is model-free with budget ``{usd: 0, calls: 0}`` (``budget.py`` refuses any -call, so a stray one fails loudly), and each writes ``summary.json`` under the task contract. Records travel as -``{"key", "version", "row"}`` lines: the store object a row came from and its version (the bucket's object version id, a file's -content hash), which a proposal's provenance cites (Req 6.4). - -- ``snapshot``: never runs in the sandbox, which cannot read the host's store. ``ousast plane run`` seeds it before - the Run (``memory.seed`` -> :func:`write_snapshot`): the store's rows after the train-on-test guard to - ``rows.jsonl``, and ``index.json`` (store, index digest, row counts, what the guard excluded). Run in a sandbox it - fails, naming the seed that did not happen. -- ``measure``: every case's ``remember`` rows (inputs ``remember_``) plus the snapshot -> ``measure.json``, - the increment's metrics for this run, the store and both (declared sites agreed, usd per candidate, dispute rates - per family, alerts per rule and pin) -- and ``rows.jsonl``, the union by row id that ``propose`` reads. -- ``propose``: ``improve/memory.py``'s rules M1/M2 over those rows after the guard (``OUSAST_QUALIFY_POPULATIONS``, the - gated manifest's cases, the pair catalog's holdout pairs), against the benchmark target's ledger and journal in - this repository at the commit under test (``OUSAST_PROJECT_DIR``) -> ``proposals.jsonl`` (edit + provenance), - ``memory_proposals.jsonl`` (the sidecar) and ``signals.json`` (M3, advisory). -- ``improve``: one ``evolve.run_round`` with those proposals -- the unchanged validator and gate, the pair catalog's - holdout clause included -- on copies of the target's ledger and journal -> ``gate.json`` (the ``RoundOutcome`` - and which edits came from memory), ``journal.json``, the sidecar, and ``rule_policy.json`` only when the round - was accepted; ``memory.jsonl`` holds the ``proposal_outcome`` rows the host ingests after the Run. Adopting an - accepted ledger stays a maintainer commit. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import shutil -import sys -import tempfile -import traceback -from collections.abc import Callable, Iterable, Mapping, Sequence -from dataclasses import asdict -from pathlib import Path -from typing import Any - -from ..memory import REMEMBER_OUTPUT, MemoryStore, Record, validate_row - -INPUT_PREFIX = "OUSAST_INPUT_REMEMBER_" -CALIBRATION = Path(".openultrasast") / "calibration" # cli.CALIBRATION_DIR: where `improve` keeps a target's ledger -ADOPTION = "adoption is a maintainer commit: copy rule_policy.json to {ledger} (README, Rule-level loop)" - - -# --- records ------------------------------------------------------------------------------------------------------- - - -def _record_line(record: Record) -> str: - return json.dumps({"key": record.key, "version": record.version, "row": record.row}, sort_keys=True, separators=(",", ":")) + "\n" - - -def read_records(path: Path) -> list[Record]: - records: list[Record] = [] - for number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - if raw.strip(): - item = json.loads(raw) - records.append(Record(validate_row(item["row"], f"{path}:{number}"), str(item["key"]), item.get("version"))) - return records - - -def write_records(path: Path, records: Iterable[Record]) -> None: - path.write_text("".join(_record_line(r) for r in sorted(records, key=lambda r: str(r.row["id"]))), encoding="utf-8") - - -def _guard(params: Mapping[str, Any], root: Path) -> Any: - from ...benchmark import load_benchmark_manifest - from ...improve.memory import Guard, benchmark_cases - from ...pairs import load_pair_catalog, select_split - - manifest = load_benchmark_manifest(root / str(params["manifest"])) - holdout = select_split(load_pair_catalog(Path(str(params["catalog"]))), "holdout") - return Guard.build([str(p) for p in params.get("populations") or []], benchmark_cases(manifest), holdout) - - -def write_snapshot(directory: Path, store: MemoryStore, params: Mapping[str, Any], root: Path | None = None) -> dict[str, Any]: - """The store's rows after the guard ``params`` names (``catalog``, ``manifest``, ``populations``; paths relative to - ``root``, the working directory) into ``directory``: ``rows.jsonl``, ``index.json`` and a ``done`` summary.""" - from ...improve.memory import index_digest - - here = Path.cwd() - try: - os.chdir(root or here) # the pair catalog names its files relative to the repository root - guard = _guard(params, Path.cwd()) - finally: - os.chdir(here) - directory.mkdir(parents=True, exist_ok=False) # after the guard loaded: a failed load leaves no half-seeded task - records = store.rows() - kept, excluded = guard.apply(records) - write_records(directory / "rows.jsonl", kept) - info = { - "store": store.describe(), "index_entries": len(store.index()), "index_digest": index_digest(store), "rows": len(records), - "kept": len(kept), "excluded": excluded, "guard": dict(params), - } # fmt: skip - (directory / "index.json").write_text(json.dumps(info, indent=2, sort_keys=True) + "\n", encoding="utf-8") - summary = {"status": "done", "units_done": 1, "units_total": 1, "usd": None, "calls": 0, "usage": {}, "model": None, "reused": info} - (directory / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") - return info - - -# --- measure ------------------------------------------------------------------------------------------------------- - - -def metrics(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: - """The increment's metrics over verdict, unit-cost and alert rows (a disputed final is neither agreed nor not).""" - verdicts = [r for r in rows if r["kind"] == "verdict"] - finals = {f: sum(1 for v in verdicts if v.get("final") == f) for f in ("agreed", "rejected", "disputed")} - costs = [r.get("usd") for r in rows if r["kind"] == "unit_cost"] - usd = None if any(c is None for c in costs) else round(sum(float(c or 0) for c in costs), 6) - families: dict[str, list[int]] = {} - for v in verdicts: - tally = families.setdefault(str(v.get("family")), [0, 0]) - tally[0] += 1 - tally[1] += v.get("final") == "disputed" - alerts: dict[str, dict[str, int]] = {} - for a in (r for r in rows if r["kind"] == "alert"): - role = "fixed" if a.get("pin_role") == "fixed" else "vulnerable" - tally_a = alerts.setdefault(str(a.get("rule_id")), {"vulnerable": 0, "fixed": 0, "fixed_in_fix_range": 0}) - tally_a[role] += 1 - tally_a["fixed_in_fix_range"] += role == "fixed" and a.get("in_fix_range") is True - coverage: dict[str, dict[str, int]] = {} # language -> pins per coverage kind: a `none` pin's zero alerts mean nothing - for c in (r for r in rows if r["kind"] == "coverage"): - tally_c = coverage.setdefault(str(c.get("language")), {"quick": 0, "engine": 0, "none": 0}) - tally_c[str(c.get("coverage"))] = tally_c.get(str(c.get("coverage")), 0) + 1 - return { - "cases": len({(v["repo"], v["pin"]) for v in verdicts}), - "candidates": len(verdicts), - **finals, - "tiebreaks": sum(1 for v in verdicts if v.get("tiebreak")), - "declared_sites_matched": sum(1 for v in verdicts if v.get("site_match") is True), - "declared_sites_agreed": sum(1 for v in verdicts if v.get("site_match") is True and v.get("final") == "agreed"), - "usd": usd, - "usd_per_candidate": None if usd is None or not verdicts else round(usd / len(verdicts), 6), - "dispute_rate": {f: {"candidates": n, "disputed": d, "rate": round(d / n, 4)} for f, (n, d) in sorted(families.items())}, - "alerts": dict(sorted(alerts.items())), - "coverage": dict(sorted(coverage.items())), - } - - -def measure(env: Mapping[str, str], output: Path) -> dict[str, Any]: - run = env.get("OUSAST_RUN") or "unknown" - current: list[Record] = [] - read: dict[str, int] = {} - for name in sorted(k for k in env if k.startswith(INPUT_PREFIX)): - path = Path(env[name]) - data = path.read_bytes() - read[name.removeprefix(INPUT_PREFIX).lower()] = len(data) - version = hashlib.sha256(data).hexdigest() - for number, raw in enumerate(data.decode("utf-8").splitlines(), 1): - if raw.strip(): - row = validate_row(json.loads(raw), f"{path}:{number}") - current.append(Record(row, f"runs/{run}/{row['task']}/{REMEMBER_OUTPUT}", version)) - if not current: - raise ValueError(f"read no remember rows from {len(read)} inputs ({read}): an unread run is not an empty one") - snapshot = read_records(Path(env["OUSAST_INPUT_SNAPSHOT"])) if env.get("OUSAST_INPUT_SNAPSHOT") else [] - merged = {str(r.row["id"]): r for r in snapshot} - merged.update({str(r.row["id"]): r for r in current}) # this run's row wins over its earlier ingest - write_records(output / "rows.jsonl", merged.values()) - report = { - "run": run, "inputs_bytes": read, "rows": {"run": len(current), "snapshot": len(snapshot), "union": len(merged)}, - "run_metrics": metrics([r.row for r in current]), "store_metrics": metrics([r.row for r in snapshot]), - "union_metrics": metrics([r.row for r in merged.values()]), - } # fmt: skip - (output / "measure.json").write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") - return {"rows": report["rows"]} - - -# --- propose and improve ------------------------------------------------------------------------------------------- - - -def _project(env: Mapping[str, str]) -> tuple[Any, Path, Path, Path]: - """(manifest, target, ledger, journal) in the repository at the commit under test; the working directory - becomes the repository root, where the pair catalog's relative paths resolve.""" - from ...benchmark import load_benchmark_manifest, resolve_benchmark_source - - project = Path(env.get("OUSAST_PROJECT_DIR") or "") - if not env.get("OUSAST_PROJECT_DIR") or not (project / str(env.get("OUSAST_LOOP_MANIFEST"))).is_file(): - raise ValueError(f"OUSAST_PROJECT_DIR {project} holds no benchmark manifest {env.get('OUSAST_LOOP_MANIFEST')}") - os.chdir(project) - manifest_path = Path(str(env["OUSAST_LOOP_MANIFEST"])) - manifest = load_benchmark_manifest(manifest_path) - target = resolve_benchmark_source(manifest_path, manifest) - ledger = target / CALIBRATION / "rule_policy.json" - return manifest, target, ledger, ledger.with_name("improve_journal.json") - - -def _params(env: Mapping[str, str]) -> dict[str, Any]: - populations = json.loads(env.get("OUSAST_QUALIFY_POPULATIONS") or "[]") - return {"catalog": env.get("OUSAST_LOOP_CATALOG"), "manifest": env.get("OUSAST_LOOP_MANIFEST"), "populations": populations} - - -def propose(env: Mapping[str, str], output: Path) -> dict[str, Any]: - from ...improve.journal import load_journal, next_round_index - from ...improve.memory import disputed_families, propose_from_memory, write_sidecar, write_signals - from ...ruleset import DEFAULT_RULESET_DIR, load_ruleset, read_rule_ledger - - records = read_records(Path(env["OUSAST_INPUT_ROWS"])) - index = json.loads(Path(env["OUSAST_INPUT_INDEX"]).read_text(encoding="utf-8")) - _, _, ledger, journal = _project(env) - guard = _guard(_params(env), Path.cwd()) - rules = load_ruleset(DEFAULT_RULESET_DIR, ledger if ledger.is_file() else None) - rounds = load_journal(journal) - found = propose_from_memory( - records, {r.rule_id: r for r in rules}, read_rule_ledger(ledger), rounds, excluded=guard, index_digest=str(index["index_digest"]) - ) - lines = [json.dumps({"edit": asdict(p.edit), "provenance": p.provenance}, sort_keys=True) + "\n" for p in found] - (output / "proposals.jsonl").write_text("".join(lines), encoding="utf-8") - sidecar = output / "journal.json" - write_sidecar(sidecar, next_round_index(rounds), found) - sidecar.with_name("memory_proposals.jsonl").touch() # present when nothing was proposed, as a declared output - write_signals(output, disputed_families(records, guard)) - kept, excluded = guard.apply(records) - return {"proposals": len(found), "rows": len(records), "kept": len(kept), "excluded": excluded["rows"]} - - -def improve(env: Mapping[str, str], output: Path) -> dict[str, Any]: - from ...gate import FALSE_POSITIVE_CEILING, RECALL_FLOOR - from ...improve.evolve import run_round - from ...improve.memory import MemoryProposal, outcome_of, outcome_rows, proposal_id_of - from ...improve.validator import RuleStatusEdit - from ...pairs import load_pair_catalog, select_split - from ...policy import load_policy - from ...ruleset import DEFAULT_RULESET_DIR - - path = Path(env["OUSAST_INPUT_PROPOSALS"]) - items = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] - proposals = [MemoryProposal(RuleStatusEdit(**item["edit"]), item["provenance"]) for item in items] - manifest, target, ledger, journal = _project(env) - pairs = select_split(load_pair_catalog(Path(str(env["OUSAST_LOOP_CATALOG"]))), "holdout") - with tempfile.TemporaryDirectory(prefix="ousast-loop-") as scratch: - work_ledger = Path(scratch) / "rule_policy.json" - if ledger.is_file(): - shutil.copyfile(ledger, work_ledger) - if journal.is_file(): - shutil.copyfile(journal, output / "journal.json") - outcome = run_round( - target, manifest, ledger_path=work_ledger, journal_path=output / "journal.json", ruleset_dir=DEFAULT_RULESET_DIR, - policy=load_policy(), recall_floor=RECALL_FLOOR, fp_ceiling=FALSE_POSITIVE_CEILING, pair_cases=pairs, proposals=proposals, - ) # fmt: skip - if outcome.accepted and work_ledger.is_file(): - shutil.copyfile(work_ledger, output / "rule_policy.json") - (output / "journal.json").touch() - (output / "memory_proposals.jsonl").touch() - memory_edits = [e.key() for e in outcome.edits if proposal_id_of(e)] - verdict = { - "accepted": outcome.accepted, "reason": outcome.reason, "round": outcome.round, "proposals": len(proposals), - "memory_edits": memory_edits, "benchmark_edits": [e.key() for e in outcome.edits if not proposal_id_of(e)], - "manifest": env.get("OUSAST_LOOP_MANIFEST"), "outcome": asdict(outcome), - "adoption": ADOPTION.format(ledger=ledger.as_posix()) if outcome.accepted else None, - } # fmt: skip - (output / "gate.json").write_text(json.dumps(verdict, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8") - status = outcome_of(outcome.reason, outcome.accepted) - by_id = {p.proposal_id: p for p in proposals} - gate_run = f"plane:{env.get('OUSAST_RUN') or 'unknown'}" - rows = outcome_rows(outcome.edits, status, outcome.round, outcome.reason, by_id, gate_run) if status else [] - (output / REMEMBER_OUTPUT).write_text("".join(json.dumps(r, sort_keys=True) + "\n" for r in rows), encoding="utf-8") - return {"accepted": outcome.accepted, "reason": outcome.reason, "edits": len(outcome.edits), "memory_edits": len(memory_edits)} - - -def snapshot(env: Mapping[str, str], output: Path) -> dict[str, Any]: - raise RuntimeError( - "memory-snapshot ran in the sandbox: `ousast plane run` writes it on the host from the memory store before the Run " - "starts (memory.seed), so the seed did not happen; rerun through `ousast plane run`" - ) - - -STEPS: dict[str, Callable[[Mapping[str, str], Path], dict[str, Any]]] = { - "snapshot": snapshot, "measure": measure, "propose": propose, "improve": improve, -} # fmt: skip - - -def main(argv: Sequence[str] | None = None, environ: Mapping[str, str] | None = None) -> int: - args = list(sys.argv[1:] if argv is None else argv) - env = environ if environ is not None else os.environ - output = Path(env["OUSAST_OUTPUT_DIR"]) if env.get("OUSAST_OUTPUT_DIR") else None - summary: dict[str, Any] = {"status": "failed", "units_done": 0, "units_total": 1, "usd": 0, "calls": 0, "usage": {}, "model": None} - here = Path.cwd() - try: - if output is None: - raise ValueError("OUSAST_OUTPUT_DIR is not set") - if len(args) != 1 or args[0] not in STEPS: - raise ValueError(f"expected one step of {sorted(STEPS)}, got {args}") - output.mkdir(parents=True, exist_ok=True) - summary.update(step=args[0], **STEPS[args[0]](env, output)) - summary.update(status="done", units_done=1) - except Exception: # noqa: BLE001 -- a crash is `failed` with its traceback - summary["reason"] = traceback.format_exc()[-2000:] - finally: - os.chdir(here) - if output is not None: - (output / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True, default=str) + "\n", encoding="utf-8") - shown = {k: summary.get(k) for k in ("status", "step", "proposals", "accepted", "reason", "rows")} - print(json.dumps(shown, default=str), file=sys.stderr) - return 0 if summary["status"] == "done" else 2 - - -__all__ = ["main", "measure", "metrics", "propose", "improve", "read_records", "write_records", "write_snapshot"] - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/src/openultrasast/ruleset/store.py b/src/openultrasast/ruleset/store.py index 156259e..c196043 100644 --- a/src/openultrasast/ruleset/store.py +++ b/src/openultrasast/ruleset/store.py @@ -20,8 +20,8 @@ class RulesetError(ValueError): class PatternRule: """A detection rule expressed as governed data (no rule-local severity). - ``pattern`` is human-PR-only; the self-improvement loop may only change - ``status``/``min_evidence_level``/``precision_estimate`` via the ledger. + Patterns change through reviewed commits. The ledger overlays + ``status``, ``min_evidence_level`` and ``precision_estimate``. """ rule_id: str @@ -42,10 +42,10 @@ class PatternRule: def load_ruleset(directory: Path = DEFAULT_RULESET_DIR, ledger: Path | None = None, *, priors: Priors = "all") -> tuple[PatternRule, ...]: - """Load every ``*.toml`` rule file under ``directory`` and apply the loop ledger. + """Load every ``*.toml`` rule file under ``directory`` and apply the rule policy ledger. Rules are returned sorted by ``rule_id`` for determinism. The optional ledger - (``rule_policy.json``) overlays loop-owned ``status``/``min_evidence_level``/ + (``rule_policy.json``) overlays ``status``/``min_evidence_level``/ ``precision_estimate`` per ``rule_id``. ``priors`` keeps or drops the rules tagged ``framework``/``library``: ``"all"`` (the default, today's rules), @@ -103,12 +103,12 @@ def write_ruleset(path: Path, rules: Iterable[PatternRule]) -> None: def read_rule_ledger(path: Path) -> dict[str, dict[str, object]]: - """Read the loop-owned ``rule_policy.json`` overlay (status/floors per rule_id).""" + """Read the ``rule_policy.json`` overlay (status/floors per rule_id).""" return _load_ledger(path) def write_rule_ledger(path: Path, entries: dict[str, dict[str, object]]) -> None: - """Write the loop-owned ``rule_policy.json`` overlay (used by the evolve loop).""" + """Write the ``rule_policy.json`` overlay.""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(entries, indent=2, sort_keys=True) + "\n") diff --git a/tests/test_cli_improve.py b/tests/test_cli_improve.py deleted file mode 100644 index 0a4de47..0000000 --- a/tests/test_cli_improve.py +++ /dev/null @@ -1,69 +0,0 @@ -"""`ousast improve` CLI: drives the self-improvement loop and the loop-owned ledger.""" - -from pathlib import Path - -from openultrasast.cli import main -from openultrasast.ruleset import PatternRule, read_rule_ledger, write_ruleset - - -def _rule(rule_id: str, pattern: str) -> PatternRule: - return PatternRule(rule_id=rule_id, title=rule_id, languages=("python",), cwe="CWE-95", tags=("injection",), pattern=pattern) - - -def _expected_block(rule_id: str) -> str: - return ( - '[[expected]]\ncwe = "CWE-95"\nclass = "code injection"\npath = "app.py"\n' - f'line = 2\nrule_id = "{rule_id}"\nsink = "eval"\nevidence = "eval executes input"\n' - ) - - -def _scaffold(tmp_path: Path) -> tuple[Path, Path]: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n") - ruleset_dir = tmp_path / "ruleset" - write_ruleset(ruleset_dir / "python" / "rules.toml", [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")]) - manifest = tmp_path / "bench.toml" - manifest.write_text('name = "t"\nlanguage = "python_web"\n\n[source]\npath = "repo"\n\n' + _expected_block("good-eval")) - return repo, ruleset_dir - - -def test_improve_accepts_and_writes_loop_owned_ledger(tmp_path: Path) -> None: - repo, ruleset_dir = _scaffold(tmp_path) - manifest = tmp_path / "bench.toml" - - exit_code = main(["improve", str(manifest), "--ruleset-dir", str(ruleset_dir)]) - - assert exit_code == 0 - # The ledger lands exactly where `scan`/`benchmark` read it for this target. - ledger = repo / ".openultrasast" / "calibration" / "rule_policy.json" - assert ledger.exists() - assert read_rule_ledger(ledger)["noisy-print"]["status"] == "shadow" - journal = repo / ".openultrasast" / "calibration" / "improve_journal.json" - assert journal.exists() - - -def test_improve_dry_run_never_touches_the_targets_ledger(tmp_path: Path) -> None: - repo, ruleset_dir = _scaffold(tmp_path) - manifest = tmp_path / "bench.toml" - - exit_code = main(["improve", str(manifest), "--ruleset-dir", str(ruleset_dir), "--dry-run"]) - - assert exit_code == 0 - assert not (repo / ".openultrasast" / "calibration" / "rule_policy.json").exists() - - -def test_improve_rejects_missing_manifest(tmp_path: Path) -> None: - import pytest - - with pytest.raises(SystemExit): - main(["improve", str(tmp_path / "nope.toml")]) - - -def test_improve_fails_loud_when_pair_catalog_is_missing(tmp_path: Path) -> None: - import pytest - - _, ruleset_dir = _scaffold(tmp_path) - manifest = tmp_path / "bench.toml" - with pytest.raises(SystemExit, match="pair catalog"): - main(["improve", str(manifest), "--ruleset-dir", str(ruleset_dir), "--dry-run", "--pair-catalog", str(tmp_path / "missing.toml")]) diff --git a/tests/test_gate.py b/tests/test_gate.py index 1d9427a..db223f1 100644 --- a/tests/test_gate.py +++ b/tests/test_gate.py @@ -132,8 +132,6 @@ GOVERNANCE_SCORING_BENCHMARK_PLANES = ( "openultrasast.scoring.project_score", "openultrasast.ruleset", "openultrasast.ruleset.store", - "openultrasast.improve", - "openultrasast.improve.evolve", "openultrasast.fusion", "openultrasast.skills", "openultrasast.redaction", diff --git a/tests/test_improve.py b/tests/test_improve.py deleted file mode 100644 index 6839ecb..0000000 --- a/tests/test_improve.py +++ /dev/null @@ -1,324 +0,0 @@ -"""Self-improvement loop: bounded levers, validator, gates, accept/revert (Phase 3 task 7).""" - -import json -from pathlib import Path - -import pytest - -from openultrasast.benchmark import load_benchmark_manifest -from openultrasast.improve import ( - EvolveValidator, - PolicyConstantEdit, - RuleStatusEdit, - StrictValidationError, - build_rule_signals, - propose_status_edits, - run_improvement, - run_round, -) -from openultrasast.policy import load_policy -from openultrasast.ruleset import PatternRule, read_rule_ledger, write_ruleset - -POLICY = load_policy() - - -def _rule(rule_id: str, pattern: str, cwe: str = "CWE-95") -> PatternRule: - return PatternRule(rule_id=rule_id, title=rule_id, languages=("python",), cwe=cwe, tags=("injection",), pattern=pattern) - - -def _expected(rule_id: str) -> str: - return ( - '[[expected]]\ncwe = "CWE-95"\nclass = "code injection"\npath = "app.py"\n' - f'line = 2\nrule_id = "{rule_id}"\nsink = "eval"\nevidence = "eval executes input"\n' - ) - - -# ---- validator (7.1, 7.2) --------------------------------------------------- - - -def test_validator_rejects_enabled_to_disabled_jump() -> None: - ruleset = {"r": _rule("r", r"\beval\s*\(")} - with pytest.raises(StrictValidationError, match="shadow"): - EvolveValidator().validate(RuleStatusEdit("r", "enabled", "disabled"), ruleset, POLICY) - - -def test_validator_accepts_enabled_to_shadow() -> None: - ruleset = {"r": _rule("r", r"\beval\s*\(")} - EvolveValidator().validate(RuleStatusEdit("r", "enabled", "shadow"), ruleset, POLICY) # no raise - - -def test_validator_rejects_unknown_rule_and_bad_status() -> None: - ruleset = {"r": _rule("r", r"\beval\s*\(")} - with pytest.raises(StrictValidationError, match="unknown rule_id"): - EvolveValidator().validate(RuleStatusEdit("nope", "enabled", "shadow"), ruleset, POLICY) - with pytest.raises(StrictValidationError): - EvolveValidator().validate(RuleStatusEdit("r", "enabled", "bogus"), ruleset, POLICY) - - -def test_validator_never_tunes_severity_and_bounds_constants() -> None: - with pytest.raises(StrictValidationError, match="severity is upstream-owned"): - EvolveValidator().validate(PolicyConstantEdit("severity", 3, 5), {}, POLICY) - with pytest.raises(StrictValidationError): - EvolveValidator().validate(PolicyConstantEdit("K", 60, 5000), {}, POLICY) # out of bounds - EvolveValidator().validate(PolicyConstantEdit("K", 60, 90), {}, POLICY) # in bounds, no raise - - -# ---- proposer + signals (7.4, 7.6) ----------------------------------------- - - -def test_propose_status_edits_auto_shadows_precision_draggers_only() -> None: - ruleset = {"good": _rule("good", r"\beval\s*\("), "noisy": _rule("noisy", r"\bprint\s*\(")} - per_rule = {"good": {"matched": 1, "missed": 0, "false_positives": 0}, "noisy": {"matched": 0, "missed": 0, "false_positives": 2}} - - edits = propose_status_edits(per_rule, ruleset, current_ledger={}, blocked_keys=set()) - assert [(e.rule_id, e.to_status) for e in edits] == [("noisy", "shadow")] - # novelty gate: a previously-reverted edit is not re-proposed. - assert propose_status_edits(per_rule, ruleset, {}, {edits[0].key()}) == [] - # already shadow -> not re-proposed. - assert propose_status_edits(per_rule, ruleset, {"noisy": {"status": "shadow"}}, set()) == [] - - -# ---- end-to-end loop (7.5) -------------------------------------------------- - - -def _manifest(tmp_path: Path, expected_block: str) -> tuple[Path, object]: - manifest_path = tmp_path / "bench.toml" - manifest_path.write_text('name = "t"\nlanguage = "python_web"\n\n[source]\npath = "repo"\n\n' + expected_block) - return manifest_path, load_benchmark_manifest(manifest_path) - - -def _ruleset_dir(tmp_path: Path, rules: list[PatternRule]) -> Path: - rd = tmp_path / "ruleset" / "python" - write_ruleset(rd / "rules.toml", rules) - return tmp_path / "ruleset" - - -def test_round_auto_shadows_noisy_rule_and_accepts(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n") - ruleset_dir = _ruleset_dir(tmp_path, [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")]) - _, manifest = _manifest(tmp_path, _expected("good-eval")) - ledger = tmp_path / "rule_policy.json" - journal = tmp_path / "journal.json" - - outcome = run_round(repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=ruleset_dir) - - assert outcome.accepted and outcome.reason == "accepted" - assert outcome.fp_before > 0.0 and outcome.fp_after == 0.0 - assert outcome.recall_after == 1.0 # the true positive is preserved - assert read_rule_ledger(ledger)["noisy-print"]["status"] == "shadow" - assert json.loads(journal.read_text())[0]["outcome"] == "accepted" - - -def test_round_reverts_byte_for_byte_when_shadowing_would_drop_recall(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g(y):\n return eval(y)\n") - ruleset_dir = _ruleset_dir(tmp_path, [_rule("dual-eval", r"\beval\s*\(")]) - _, manifest = _manifest(tmp_path, _expected("dual-eval")) - ledger = tmp_path / "rule_policy.json" - journal = tmp_path / "journal.json" - - outcome = run_round(repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=ruleset_dir) - - assert not outcome.accepted and outcome.reason == "reverted" - assert outcome.score_after > outcome.score_before # score improved but the recall gate blocks it - assert not ledger.exists() # byte-for-byte revert: the ledger was never written - assert json.loads(journal.read_text())[0]["outcome"] == "reverted" - - -def test_run_improvement_converges_after_accepting(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n") - ruleset_dir = _ruleset_dir(tmp_path, [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")]) - _, manifest = _manifest(tmp_path, _expected("good-eval")) - - outcomes = run_improvement( - repo, - manifest, - ledger_path=tmp_path / "rule_policy.json", - journal_path=tmp_path / "journal.json", - ruleset_dir=ruleset_dir, - max_rounds=4, - ) - - assert outcomes[0].accepted # round 1 shadows the noisy rule - assert outcomes[-1].reason == "no_proposals" # converges (nothing left to improve) - - -def test_build_rule_signals_separates_miss_and_fp() -> None: - from openultrasast.benchmark import BenchmarkFalsePositive, BenchmarkMetrics, BenchmarkMiss, BenchmarkResult - - result = BenchmarkResult( - benchmark_run_id="r", - benchmark_name="n", - language="python_web", - mode="quick", - scan_id=None, - scan_run_dir=None, - metrics=BenchmarkMetrics(1, 1, 0, 1, 1, 0.0, 0.0, None, {}, {}, {}), - misses=[BenchmarkMiss("CWE-89", "sqli", "a.py", "ev", "no match", rule_id="sqli")], - false_positives=[BenchmarkFalsePositive("noisy:a.py:1", "a.py", 1, "fp", rule_id="noisy")], - baseline_deltas=[], - calibration_records=[], - rule_recommendations=[], - ) - signals = build_rule_signals(result) - assert {s["signal"] for s in signals} == {"miss", "fp"} - assert {s["rule_id"] for s in signals} == {"sqli", "noisy"} - - -# ---- pair-corpus-honesty: per-profile holdout clause (Req 7.2-7.4) ---------- - - -def _pair( - tmp_path: Path, name: str, vuln_src: str, fixed_src: str, rule_id: str, sink: str, *, provenance: str, review_tier: str = "reviewed" -): - from openultrasast.benchmark import ExpectedFinding - from openultrasast.pairs import PairCase - - vuln = tmp_path / f"{name}-vuln.py" - fixed = tmp_path / f"{name}-fixed.py" - vuln.write_text(vuln_src) - fixed.write_text(fixed_src) - return PairCase( - name=name, - slice="github", - language="python", - origin="test", - vuln_file=vuln, - fixed_file=fixed, - relpath="app.py", - expected=( - ExpectedFinding( - cwe="CWE-95", - vulnerability_class="x", - path="app.py", - evidence="", - rule_id=rule_id, - sink=sink, - mechanism="source_reaches_sink", - ), - ), - min_recall=1.0, - fix_policy="silent", - provenance=provenance, - split="holdout", - review_tier=review_tier, - reviewer="test" if review_tier == "reviewed" else "", - ) - - -def test_round_rejects_when_a_profile_regresses_on_holdout(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n") - ruleset_dir = _ruleset_dir(tmp_path, [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")]) - _, manifest = _manifest(tmp_path, _expected("good-eval")) - ledger = tmp_path / "rule_policy.json" - journal = tmp_path / "journal.json" - # The agent profile's holdout pair is only detected by the rule the round wants to shadow. - agent_pairs = [ - _pair(tmp_path, f"agent{i}", "def h(y):\n print(y)\n", "def h(y):\n return y\n", "noisy-print", "print", provenance="agent") - for i in range(5) - ] - outcome = run_round(repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=ruleset_dir, pair_cases=agent_pairs) - assert not outcome.accepted - assert outcome.reason == "profile_regression:agent" - assert outcome.profile_regressions == ["agent"] - assert outcome.per_profile_before["agent"]["pair_correct"] == 5.0 and outcome.per_profile_after["agent"]["pair_correct"] == 0.0 - assert not ledger.exists() - assert json.loads(journal.read_text())[0]["profile_regressions"] == ["agent"] - - -def test_round_accepts_when_profiles_hold_and_reports_small_profiles(tmp_path: Path) -> None: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n") - ruleset_dir = _ruleset_dir(tmp_path, [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")]) - _, manifest = _manifest(tmp_path, _expected("good-eval")) - ledger = tmp_path / "rule_policy.json" - journal = tmp_path / "journal.json" - human_pairs = [ - _pair(tmp_path, f"h{i}", "def h(y):\n return eval(y)\n", "def h(y):\n return y\n", "good-eval", "eval", provenance="human") - for i in range(5) - ] - tiny_agent = [ - _pair(tmp_path, "a0", "def h(y):\n print(y)\n", "def h(y):\n return y\n", "noisy-print", "print", provenance="agent") - ] - outcome = run_round( - repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=ruleset_dir, pair_cases=human_pairs + tiny_agent - ) - assert outcome.accepted and outcome.reason == "accepted" - assert outcome.profile_regressions == [] - assert outcome.profiles_under_minimum == ["agent"] # one pair: reported, not gated - assert ledger.exists() - - -def test_profile_gate_ignores_title_and_advisory_tiers(tmp_path: Path) -> None: - """Req 9.3: only seeded and reviewed pairs gate; a regression confined to title pairs never rejects a round.""" - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g():\n print('hello')\n") - ruleset_dir = _ruleset_dir(tmp_path, [_rule("good-eval", r"\beval\s*\("), _rule("noisy-print", r"\bprint\s*\(")]) - _, manifest = _manifest(tmp_path, _expected("good-eval")) - ledger = tmp_path / "rule_policy.json" - journal = tmp_path / "journal.json" - title_pairs = [ - _pair( - tmp_path, - f"t{i}", - "def h(y):\n print(y)\n", - "def h(y):\n return y\n", - "noisy-print", - "print", - provenance="agent", - review_tier="title", - ) - for i in range(5) - ] - outcome = run_round(repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=ruleset_dir, pair_cases=title_pairs) - assert outcome.accepted and outcome.reason == "accepted" - assert outcome.profile_regressions == [] and "agent" not in outcome.per_profile_before - ledger.unlink() - journal.unlink() - reviewed_pairs = [ - _pair(tmp_path, f"r{i}", "def h(y):\n print(y)\n", "def h(y):\n return y\n", "noisy-print", "print", provenance="agent") - for i in range(5) - ] - outcome = run_round( - repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=ruleset_dir, pair_cases=reviewed_pairs + title_pairs - ) - assert not outcome.accepted and outcome.reason == "profile_regression:agent" - assert outcome.per_profile_before["agent"]["pairs"] == 5.0 # the five title pairs are scored elsewhere, never here - - -def test_profile_gate_scores_vendored_pairs_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Req 10.4 / design: gates call select_vendored; a pointer pair never reaches the improve gate, cached or not.""" - from openultrasast.improve.evolve import evaluate_profiles - from openultrasast.pairs import PairCase - from openultrasast.ruleset import load_ruleset - - monkeypatch.setenv("OPENULTRASAST_PAIRS_NETWORK", "1") - vendored = _pair( - tmp_path, "v", "def h(y):\n return eval(y)\n", "def h(y):\n return y\n", "python-unsafe-eval", "eval", provenance="human" - ) - pointer_src = tmp_path / "cache" / "github" / "p" - pointer_src.mkdir(parents=True) - (pointer_src / "vuln.py").write_text("def h(y):\n return eval(y)\n") - (pointer_src / "fixed.py").write_text("def h(y):\n return y\n") - pointer = PairCase( - **{ - **vendored.__dict__, - "name": "p", - "provenance": "agent", - "vendored": False, - "vuln_file": pointer_src / "vuln.py", - "fixed_file": pointer_src / "fixed.py", - "recipe": (("repo", "o/r"), ("parent", "a"), ("commit", "b"), ("path", "app.py"), ("mode", "name")), - } - ) - profiles = evaluate_profiles([vendored, pointer], load_ruleset()) - assert set(profiles) == {"human"} # the agent pointer pair is scored by `pairs --pointers`, never by the gate diff --git a/tests/test_improve_memory.py b/tests/test_improve_memory.py deleted file mode 100644 index e079345..0000000 --- a/tests/test_improve_memory.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Proposals from plane memory (harnessx-removal task 3, design section 5): rules M1/M2, the advisory M3 signal, -the train-on-test guard, provenance, and the unchanged default `improve`.""" - -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - -import pytest - -from openultrasast.benchmark import load_benchmark_manifest -from openultrasast.cli import main -from openultrasast.improve import EvolveValidator, RuleStatusEdit, run_round -from openultrasast.improve.memory import ( - SIDECAR, - Guard, - MemoryProposal, - disputed_families, - outcome_of, - outcome_rows, - propose_from_memory, - read_sidecar, -) -from openultrasast.pairs import PairCase -from openultrasast.plane.memory import FileStore, Record, row_id -from openultrasast.ruleset import PatternRule, read_rule_ledger, write_ruleset - -P = {name: ch * 40 for name, ch in (("a", "a"), ("b", "b"), ("c", "c"), ("f", "f"))} -NO_GUARD = Guard() - - -def _rule(rule_id: str, pattern: str = r"\bexec\s*\(", status: str = "enabled") -> PatternRule: - return PatternRule( - rule_id=rule_id, title=rule_id, languages=("python",), cwe="CWE-95", tags=("injection",), pattern=pattern, status=status - ) - - -def _row(kind: str, repo: str, pin: str, subject: str, population: str = "dev", **fields: Any) -> dict[str, Any]: - return { - "id": row_id(kind, "run-1", f"{repo}-task", subject), "kind": kind, "repo": f"github.com/o/{repo}", "pin": pin, - "run": "run-1", "task": f"{repo}-task", "population": population, "split": "validation", "image": "sha256:x", **fields, - } # fmt: skip - - -def verdict(repo: str, pin: str, fn: str, final: str, site_match: bool | None = None, **kw: Any) -> dict[str, Any]: - cand = f"app.py::{fn}" - return _row("verdict", repo, pin, cand, candidate=cand, family="injection", final=final, site_match=site_match, **kw) - - -def alert(rule: str, repo: str, pin: str, fn: str, role: str = "vulnerable", status: str = "enabled", **kw: Any) -> dict[str, Any]: - fields = {"rule_id": rule, "rule_status": status, "path": "app.py", "line": 3, "function": fn, "pin_role": role} - return _row("alert", repo, pin, f"{rule}:{fn}:{role}", **fields, **kw) - - -def recs(rows: list[dict[str, Any]]) -> list[Record]: - return [Record(r, f"repos/{r['repo']}/{r['pin']}.jsonl", None) for r in rows] - - -def propose(rows: list[dict[str, Any]], rules: list[PatternRule], guard: Guard = NO_GUARD, ledger: Any = None) -> list[MemoryProposal]: - return propose_from_memory(recs(rows), {r.rule_id: r for r in rules}, ledger or {}, [], excluded=guard) - - -def false_alerts(rule: str = "noisy", repos: tuple[str, ...] = ("r1", "r1", "r2"), population: str = "dev") -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for i, repo in enumerate(repos): - rows.append(verdict(repo, P["a"], f"f{i}", "rejected", population=population)) - rows.append(alert(rule, repo, P["a"], f"f{i}", population=population)) - return rows - - -# ---- M1: repeated false alerts ------------------------------------------------------------------------------------- - - -def test_m1_demotes_a_rule_with_false_alerts_across_repositories() -> None: - [proposal] = propose(false_alerts(), [_rule("noisy")]) - assert (proposal.edit.from_status, proposal.edit.to_status) == ("enabled", "shadow") - assert proposal.edit.rationale == f"memory:{proposal.proposal_id}" - assert proposal.provenance["rule"] == "M1" - assert proposal.provenance["counts"] == {"false_alerts": 3, "repos": 2, "agreed_hits": 0} - assert len(proposal.provenance["evidence"]) == 6 # three alerts and the three rejected verdicts they sit at - - -@pytest.mark.parametrize("repos", [("r1", "r2"), ("r1", "r1", "r1")], ids=["two-alerts", "one-repository"]) -def test_m1_stays_silent_below_threshold(repos: tuple[str, ...]) -> None: - assert propose(false_alerts(repos=repos), [_rule("noisy")]) == [] - - -def test_m1_never_demotes_a_rule_with_an_agreed_hit() -> None: - rows = false_alerts() + [verdict("r3", P["b"], "hit", "agreed"), alert("noisy", "r3", P["b"], "hit")] - assert propose(rows, [_rule("noisy")]) == [] - site = false_alerts() + [verdict("r3", P["b"], "site", "rejected", site_match=True), alert("noisy", "r3", P["b"], "site")] - assert propose(site, [_rule("noisy")]) == [] - - -def test_m1_counts_fixed_pin_alerts_in_the_fix_range_and_ignores_disputes() -> None: - rows = [ - verdict("r1", P["a"], "sink", "agreed", site_match=True), - verdict("r2", P["b"], "sink", "agreed", site_match=True), - alert("noisy", "r1", P["f"], "sink", role="fixed"), # the declared site's function on the fixed pin - alert("noisy", "r2", P["f"], "sink", role="fixed"), - alert("noisy", "r2", P["c"], "other", role="fixed", in_fix_range=True), - verdict("r1", P["a"], "maybe", "disputed"), - alert("noisy", "r1", P["a"], "maybe"), # disputed: evidence in neither direction - ] - [proposal] = propose(rows, [_rule("noisy")]) - assert proposal.provenance["counts"]["false_alerts"] == 3 - - -def test_m1_skips_rules_the_ruleset_does_not_know_or_that_are_not_enabled() -> None: - assert propose(false_alerts(rule="ghost"), [_rule("noisy")]) == [] - assert propose(false_alerts(), [_rule("noisy")], ledger={"noisy": {"status": "shadow"}}) == [] - - -# ---- M2: missed declared sites ------------------------------------------------------------------------------------- - - -def site_hits(rule: str = "quiet") -> list[dict[str, Any]]: - return [ - verdict("r1", P["a"], "s1", "agreed", site_match=True), - verdict("r2", P["b"], "s2", "agreed", site_match=True), - alert(rule, "r1", P["a"], "s1", status="shadow"), - alert(rule, "r2", P["b"], "s2", status="shadow"), - ] - - -def test_m2_promotes_a_shadow_rule_that_hits_declared_sites_nothing_else_catches() -> None: - [proposal] = propose(site_hits(), [_rule("quiet", status="shadow"), _rule("other")]) - assert (proposal.edit.from_status, proposal.edit.to_status, proposal.provenance["rule"]) == ("shadow", "enabled", "M2") - assert proposal.provenance["counts"]["site_hits"] == 2 - - -def test_m2_stays_silent_below_threshold_or_when_already_caught() -> None: - rules = [_rule("quiet", status="shadow"), _rule("other")] - assert propose(site_hits()[:3], rules) == [] # one hit - assert propose([*site_hits(), alert("other", "r1", P["a"], "s1")], rules) == [] # an enabled rule catches s1 - - -def test_m2_stays_silent_on_a_fixed_pin_alert_or_rejected_candidates() -> None: - rules = [_rule("quiet", status="shadow")] - assert propose([*site_hits(), alert("quiet", "r1", P["f"], "s1", role="fixed", status="shadow")], rules) == [] - rejected = [ - verdict("r3", P["c"], "x1", "rejected"), - verdict("r3", P["c"], "x2", "rejected"), - alert("quiet", "r3", P["c"], "x1", status="shadow"), - alert("quiet", "r3", P["c"], "x2", status="shadow"), - ] - assert propose([*site_hits(), *rejected[::2]], rules) != [] # one rejected alert is tolerated - assert propose([*site_hits(), *rejected], rules) == [] - - -# ---- coverage: no rule could fire, so no evidence either way ------------------------------------------------------- - - -def covered(repo: str, pin: str, kind: str, language: str = "python", role: str = "vulnerable") -> dict[str, Any]: - return _row("coverage", repo, pin, f"{language}:{role}:{kind}", language=language, coverage=kind, files=3, pin_role=role) - - -def test_m1_skips_a_repository_and_pin_whose_coverage_for_the_rules_language_is_none() -> None: - blind = [*false_alerts(), covered("r2", P["a"], "none")] - assert propose(blind, [_rule("noisy")]) == [], "r2's alert could not be evidence: one repository is left" - wider = [*false_alerts(repos=("r1", "r1", "r2", "r3")), covered("r3", P["a"], "none")] - [proposal] = propose(wider, [_rule("noisy")]) - assert proposal.provenance["counts"] == {"false_alerts": 3, "repos": 2, "agreed_hits": 0} - assert proposal.provenance["excluded"]["coverage_none"] == {f"github.com/o/r3@{P['a']}": 1} - - -def test_coverage_none_of_another_language_or_beside_engine_coverage_is_no_skip() -> None: - other = [*false_alerts(), covered("r2", P["a"], "none", language="php")] - assert len(propose(other, [_rule("noisy")])) == 1, "the rule's language (python) was covered there" - engine = [*false_alerts(), covered("r2", P["a"], "none"), covered("r2", P["a"], "engine")] - assert len(propose(engine, [_rule("noisy")])) == 1, "a later run covered the pin: its alerts are evidence" - - -def test_m2_skips_a_blind_repository_and_pin() -> None: - rules = [_rule("quiet", status="shadow"), _rule("other")] - assert propose([*site_hits(), covered("r2", P["b"], "none")], rules) == [] - - -# ---- M3: advisory only --------------------------------------------------------------------------------------------- - - -def test_disputed_families_are_advisory_and_never_reach_the_validator(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - rows = [verdict("r1", P["a"], f"d{i}", "disputed" if i < 2 else "agreed") for i in range(5)] - [signal] = disputed_families(recs(rows), NO_GUARD) - assert signal["family"] == "injection" and signal["dispute_rate"] == 0.4 - assert disputed_families(recs(rows[1:]), NO_GUARD) == [] # four candidates: under the minimum - assert propose(rows, [_rule("noisy")]) == [] # a dispute is no proposal - - seen: list[object] = [] - original = EvolveValidator.validate - monkeypatch.setattr(EvolveValidator, "validate", lambda self, edit, *a: (seen.append(edit), original(self, edit, *a))[1]) - repo, rules_dir, manifest = _scaffold(tmp_path) - [proposal] = propose(false_alerts(), [_rule("noisy")]) - run_round( - repo, manifest, ledger_path=tmp_path / "l.json", journal_path=tmp_path / "j.json", ruleset_dir=rules_dir, proposals=[proposal] - ) - assert seen and all(isinstance(edit, RuleStatusEdit) for edit in seen) - - -# ---- the train-on-test guard --------------------------------------------------------------------------------------- - - -def test_guard_drops_the_qualification_population_and_reports_it() -> None: - rows = false_alerts(population="independent-v2/validation") + false_alerts(repos=("r4",), population="dev") - guard = Guard.build(populations=["independent-v2/validation"]) - kept, excluded = guard.apply(recs(rows)) - assert len(kept) == 2 and excluded["rows"] == 6 and excluded["population"] == {"independent-v2/validation": 6} - assert propose(rows, [_rule("noisy")], guard) == [] # the only evidence is excluded: no proposal - assert propose(rows, [_rule("noisy")]) != [] # the same rows without the guard would propose - - -def test_guard_drops_holdout_pair_repositories_and_gated_cases() -> None: - holdout = PairCase( - name="h", slice="s", language="python", origin="o", vuln_file=Path("v"), fixed_file=Path("f"), relpath="app.py", - expected=(), min_recall=1.0, fix_policy="silent", repo="O/R1", split="holdout", - ) # fmt: skip - guard = Guard.build(gated_cases=[("https://github.com/o/r2", P["a"])], holdout_pairs=[holdout]) - kept, excluded = guard.apply(recs(false_alerts())) - assert kept == [] - assert excluded["holdout_pair"] == {f"github.com/o/r1@{P['a']}": 4} - assert excluded["gated_case"] == {f"github.com/o/r2@{P['a']}": 2} - [proposal] = propose(false_alerts(repos=("r1", "r2", "r3", "r3", "r4")), [_rule("noisy")], guard) - assert {e["repo"] for e in proposal.provenance["evidence"]} == {"github.com/o/r3", "github.com/o/r4"} - assert proposal.provenance["excluded"]["rows"] == 4 - - -# ---- provenance, the round, and outcomes --------------------------------------------------------------------------- - - -def _scaffold(tmp_path: Path) -> tuple[Path, Path, Any]: - repo = tmp_path / "repo" - repo.mkdir() - (repo / "app.py").write_text("def f(x):\n return eval(x)\n") - rules_dir = tmp_path / "ruleset" - write_ruleset(rules_dir / "python" / "rules.toml", [_rule("good-eval", r"\beval\s*\("), _rule("noisy")]) - manifest = tmp_path / "bench.toml" - manifest.write_text( - 'name = "t"\nlanguage = "python_web"\n\n[source]\npath = "repo"\n\n[[expected]]\ncwe = "CWE-95"\n' - 'class = "code injection"\npath = "app.py"\nline = 2\nrule_id = "good-eval"\nsink = "eval"\nevidence = "eval"\n' - ) - return repo, rules_dir, load_benchmark_manifest(manifest) - - -def test_provenance_round_trips_journal_sidecar_store(tmp_path: Path) -> None: - store = FileStore(tmp_path / "memory") - store.ingest_rows("run-1", "t", false_alerts()) - records = store.rows() - [proposal] = propose_from_memory(records, {"noisy": _rule("noisy")}, {}, [], excluded=NO_GUARD, index_digest="idx") - repo, rules_dir, manifest = _scaffold(tmp_path) - journal, ledger = tmp_path / "cal" / "journal.json", tmp_path / "cal" / "rule_policy.json" - - outcome = run_round(repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=rules_dir, proposals=[proposal]) - - assert outcome.accepted and read_rule_ledger(ledger)["noisy"]["status"] == "shadow" - [entry] = json.loads(journal.read_text())[0]["edits"] - assert entry["evidence"] == proposal.proposal_id and entry["rationale"] == f"memory:{proposal.proposal_id}" - side = read_sidecar(journal)[entry["evidence"]] - assert side["edit_key"] == entry["key"] and side["index_digest"] == "idx" and side["round"] == 1 - by_id = {rec.row["id"]: rec for rec in records} - for ev in side["evidence"]: # every cited row is in the store, at the object and version recorded - assert by_id[ev["id"]].key == ev["key"] and by_id[ev["id"]].version == ev["version"] - assert ev["version"].startswith("sha256:") - - rows = outcome_rows( - outcome.edits, outcome_of(outcome.reason, outcome.accepted) or "", 1, outcome.reason, {proposal.proposal_id: proposal}, "g" - ) - store.put_rows(rows) - back = store.rows(kind="proposal_outcome") - assert {r.row["outcome"] for r in back} == {"accepted"} and {r.row["proposal_id"] for r in back} == {proposal.proposal_id} - assert len(back) == 2 # one per (repository, pin) of the evidence - - again = run_round(repo, manifest, ledger_path=ledger, journal_path=journal, ruleset_dir=rules_dir, proposals=[proposal]) - assert again.reason == "no_proposals" # the rule is shadow now: the proposal no longer applies - - -def test_a_reverted_memory_proposal_is_not_retried_on_the_same_evidence(tmp_path: Path) -> None: - repo, rules_dir, manifest = _scaffold(tmp_path) - rows = [verdict(r, P["a"], f"f{i}", "rejected") for i, r in enumerate(("r1", "r1", "r2"))] - rows += [alert("good-eval", r, P["a"], f"f{i}") for i, r in enumerate(("r1", "r1", "r2"))] - [proposal] = propose(rows, [_rule("good-eval", r"\beval\s*\(")]) - journal = tmp_path / "j.json" - first = run_round(repo, manifest, ledger_path=tmp_path / "l.json", journal_path=journal, ruleset_dir=rules_dir, proposals=[proposal]) - assert first.reason == "reverted" and outcome_of(first.reason, first.accepted) == "reverted" # shadowing drops recall - second = run_round(repo, manifest, ledger_path=tmp_path / "l.json", journal_path=journal, ruleset_dir=rules_dir, proposals=[proposal]) - assert second.reason == "no_proposals" - assert outcome_of("validation_failed: x", False) == "rejected" and outcome_of("no_proposals", False) is None - - -def test_default_round_journal_has_no_evidence_and_no_sidecar(tmp_path: Path) -> None: - repo, rules_dir, manifest = _scaffold(tmp_path) - (repo / "app.py").write_text("def f(x):\n return eval(x)\ndef g(y):\n exec(y)\n") - journal = tmp_path / "j.json" - outcome = run_round(repo, manifest, ledger_path=tmp_path / "l.json", journal_path=journal, ruleset_dir=rules_dir) - assert outcome.edits # the benchmark's own edit - [entry] = json.loads(journal.read_text())[0]["edits"] - assert set(entry) == {"key", "lever", "rule_id", "from", "to", "rationale"} - assert not (tmp_path / SIDECAR).exists() - - -# ---- the CLI ------------------------------------------------------------------------------------------------------- - - -def test_default_improve_output_matches_the_recorded_baseline(capsys: pytest.CaptureFixture[str]) -> None: - root = Path(__file__).resolve().parents[1] - baseline = root / "benchmarks/measurements/2026-09-30-harnessx-removal-baseline/improve/stdout.txt" - manifest = "benchmarks/manifests/java-spring-boot-vulnerable.toml" - import os - - cwd = Path.cwd() - os.chdir(root) - try: - assert main(["improve", manifest, "--dry-run", "--no-pair-gate"]) == 0 - finally: - os.chdir(cwd) - assert capsys.readouterr().out.replace(str(root), "") == baseline.read_text() - - -def test_improve_memory_with_an_empty_store_has_no_proposals(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: - repo, rules_dir, _ = _scaffold(tmp_path) - store = tmp_path / "memory" - store.mkdir() - (store / "index.jsonl").write_text("") - catalog = Path(__file__).resolve().parents[1] / "benchmarks/pairs/catalog.toml" - args = ["improve", str(tmp_path / "bench.toml"), "--ruleset-dir", str(rules_dir), "--dry-run", "--pair-catalog", str(catalog)] - assert main([*args, "--no-pair-gate", "--memory", str(store)]) == 0 - out = capsys.readouterr().out - assert "rows=0" in out and "memory_proposals=0" in out and "round 1: no_proposals" in out - with pytest.raises(SystemExit, match="no index.jsonl"): - main([*args, "--memory", str(tmp_path / "absent")]) diff --git a/tests/test_plane_engine_alerts.py b/tests/test_plane_engine_alerts.py index 4190dcd..d8c051c 100644 --- a/tests/test_plane_engine_alerts.py +++ b/tests/test_plane_engine_alerts.py @@ -13,12 +13,12 @@ from pathlib import Path from typing import Any import pytest -from test_plane_loop import LOOP, _templates, _write +import yaml from openultrasast import cli from openultrasast.plane import engine_alerts from openultrasast.plane.engine_alerts import EngineAlertsError, alerts_engine, engine_rows, parse_site, read_record -from openultrasast.plane.generate import CaseInputs, render +from openultrasast.plane.manifests import RUN_API_VERSION FIXTURE = Path(__file__).parent / "fixtures" / "plane-engine" CASE = "php-probe" @@ -44,17 +44,29 @@ def world(tmp_path: Path) -> dict[str, Any]: (repo / "app" / "index.php").write_text(text) _git(repo, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "commit", "-qam", "fixed") fixed = _git(repo, "rev-parse", "HEAD") - record = {"id": CASE, "repo": "https://example.com/reserved/probe", "family": "injection", "vulnerable": vulnerable, "fixed": fixed} - candidate = ("app/index.php", "", 3) - case = CaseInputs( - record, (candidate,), (candidate,), {}, 0.0, ranges={"app/index.php": [(3, 3)]}, fixed_ranges={"app/index.php": [(3, 3)]} - ) - files = render([case], _templates(), "validation-46", "t", population="p", split="s", loop=LOOP) + plane = tmp_path / "plane" + for name in ("tasks", "workspaces", "runs"): + (plane / name).mkdir(parents=True) + task = yaml.safe_load((Path(__file__).resolve().parents[1] / "plane/tasks/alerts.yaml").read_text()) + task["metadata"]["name"] = f"{CASE}-alerts" + task["spec"]["env"] = [{"name": f"OUSAST_{role.upper()}_PIN", "value": pin} for role, pin in + (("vulnerable", vulnerable), ("fixed", fixed))] + task["spec"]["workspaces"] = [{"name": "case", "path": "/workspace/case"}] + (plane / "tasks/alerts.yaml").write_text(yaml.safe_dump(task)) + case = {"family": "injection", "ranges": {"app/index.php": [[3, 3]]}, "fixed_ranges": {"app/index.php": [[3, 3]]}} + workspace = {"apiVersion": task["apiVersion"], "kind": "Workspace", "metadata": {"name": "case"}, "spec": { + "git": [{"name": "repo", "repo": "https://example.com/reserved/probe", "dir": "repo"}], + "files": [{"path": "case.json", "content": json.dumps(case)}], + }} + (plane / "workspaces/case.yaml").write_text(yaml.safe_dump(workspace)) + run = plane / "runs/validation-46.yaml" + run.write_text(yaml.safe_dump({"apiVersion": RUN_API_VERSION, "kind": "Run", "metadata": {"name": "validation-46"}, + "spec": {"tasks": [{"name": f"{CASE}-alerts", "task": f"{CASE}-alerts", + "outputs": ["alerts.jsonl", "summary.json"], "budget": {"usd": 0, "calls": 0}}]}})) source = tmp_path / "frozen" (source / "src" / "openultrasast").mkdir(parents=True) (source / "benchmarks" / "push").mkdir(parents=True) (source / "benchmarks" / "push" / "finding_dump.py").write_text("") - run = _write(tmp_path / "plane", files) return {"run": run, "source": source, "cache": tmp_path / "cache", "pins": (vulnerable, fixed), "results": tmp_path / "results"} diff --git a/tests/test_plane_loop.py b/tests/test_plane_loop.py deleted file mode 100644 index dda741c..0000000 --- a/tests/test_plane_loop.py +++ /dev/null @@ -1,313 +0,0 @@ -"""The improvement loop as a plane Run (harnessx-removal Req 6.3, design section 6). - -The whole graph runs through the reconciler and the fake ax of ``test_plane_reconciler``, whose ``exec`` mode runs the -real task modules on the inputs the receiver serves: a recorded case's chain (validation-46's lmdeploy case from -``tests/fixtures/plane-remember``, already done) -> ``alerts`` -> ``remember`` -> ``loop-measure`` -> ``loop-propose`` --> ``loop-improve``, with ``memory-snapshot`` seeded on the host from a crafted store. -""" - -from __future__ import annotations - -import json -import shutil -import subprocess -from pathlib import Path -from typing import Any - -import pytest -import yaml -from test_plane_reconciler import Fake, fake # noqa: F401 -- `fake` is the fixture - -import openultrasast -from openultrasast import cli -from openultrasast.plane import reconciler -from openultrasast.plane.generate import TEMPLATES, CaseInputs, Loop, fix_ranges, render -from openultrasast.plane.manifests import load_manifests -from openultrasast.plane.memory import SNAPSHOT_ANNOTATION, FileStore, row_id -from openultrasast.plane.tasks import alerts - -FIXTURE = Path(__file__).parent / "fixtures" / "plane-remember" / "validation-46" -PLANE = Path(__file__).resolve().parents[1] / "plane" -SRC = str(Path(openultrasast.__file__).resolve().parents[1]) -CASE = "lmdeploy-media-url-ssrf" -VULNERABLE = "71648d239c978452720a6d6692fe914efc8a7b94" -FIXED = "ab" * 20 -OTHER = "12" * 20 -EVAL = "python-unsafe-eval" # a bundled, enabled rule -CHAIN = ("facts", "va", "vb", "agree", "vc", "final") -LOOP_TASKS = ("memory-snapshot", "loop-measure", "loop-propose", "loop-improve") - - -def _case(**ranges: Any) -> CaseInputs: - record = { - "id": CASE, "repo": "https://example.com/reserved/lmdeploy", "family": "untrusted_destination", "vulnerable": VULNERABLE, - "fixed": FIXED, "sites": ["lmdeploy/vl/media/connection.py::_load_http_url"], - } # fmt: skip - candidate = ("lmdeploy/vl/media/connection.py", "_load_http_url", 40) - return CaseInputs(record, (candidate,), (candidate,), {}, 0.02, **ranges) - - -def _templates() -> dict: - return load_manifests([PLANE / "tasks" / f"{name}.yaml" for name in TEMPLATES]).tasks - - -LOOP = Loop(repo="https://example.invalid/openultrasast.git", commit="c" * 40, manifest="bench.toml", catalog="catalog.toml") - - -def _write(plane: Path, files: dict[str, str]) -> Path: - shutil.copytree(PLANE / "models", plane / "models") - for relative, text in files.items(): - (plane / relative).parent.mkdir(parents=True, exist_ok=True) - (plane / relative).write_text(text) - return plane / "runs" / "validation-46.yaml" - - -# --- alerts -------------------------------------------------------------------------------------------------------- - - -def test_alerts_attribute_functions_and_mark_the_fix_on_both_pins(tmp_path: Path) -> None: - vulnerable, fixed = tmp_path / "v", tmp_path / "f" - (vulnerable / "pkg").mkdir(parents=True) - (fixed / "pkg").mkdir(parents=True) - (vulnerable / "pkg" / "run.py").write_text("import os\n\ndef handle(x):\n y = x\n return eval(y)\n") - (fixed / "pkg" / "run.py").write_text("import os\n\ndef handle(x):\n return eval(x, {})\n\nprint(eval('1'))\n") - case = tmp_path / "case.json" - case.write_text(json.dumps({"ranges": {"pkg/run.py": [[4, 5]]}, "fixed_ranges": {"pkg/run.py": [[4, 4]]}})) - out = tmp_path / "out" - env = { - "OUSAST_OUTPUT_DIR": str(out), "OUSAST_WORKSPACE_DIR": str(vulnerable), "OUSAST_FIXED_DIR": str(fixed), - "OUSAST_INPUT_CASE": str(case), "OUSAST_VULNERABLE_PIN": VULNERABLE, "OUSAST_FIXED_PIN": FIXED, - } # fmt: skip - assert alerts.main(env) == 0 - rows = [json.loads(line) for line in (out / "alerts.jsonl").read_text().splitlines()] - got = [(r["rule_id"], r["pin_role"], r["pin"], r["line"], r["function"], r["in_fix_range"]) for r in rows if r["rule_id"] == EVAL] - assert got == [ - (EVAL, "vulnerable", VULNERABLE, 5, "handle", True), - (EVAL, "fixed", FIXED, 4, "handle", True), - (EVAL, "fixed", FIXED, 6, "", False), - ] - summary = json.loads((out / "summary.json").read_text()) - assert (summary["status"], summary["units_done"], summary["usd"], summary["calls"], summary["model"]) == ("done", 2, 0, 0, None) - assert summary["read"]["vulnerable"]["files"] == 1 and summary["read"]["vulnerable"]["bytes"] > 0, "the scan read its tree" - assert alerts.in_range(None, "pkg/run.py", 4) is None, "no ranges: unknown, not outside" - - empty = tmp_path / "empty" - empty.mkdir() - assert alerts.main({**env, "OUSAST_FIXED_DIR": str(empty)}) == 2 - assert "an unread tree is not a clean one" in json.loads((out / "summary.json").read_text())["reason"] - - -def test_quick_coverage_is_derived_from_the_ruleset_directory(tmp_path: Path) -> None: - shipped = alerts.quick_languages() - assert {"python", "javascript", "typescript", "java", "c", "groovy", "php"} <= shipped, "PHP has quick rules" - assert "php" in alerts.engine_languages(), "the engine's semantic models cover PHP" - rules = tmp_path / "ruleset" / "php" - rules.mkdir(parents=True) - rule = 'rule_id = "php-x"\ntitle = "x"\nlanguages = ["php"]\ncwe = "CWE-89"\ntags = []\npattern = "x"\n' - (rules / "rules.toml").write_text(f"[[rule]]\n{rule}") - assert alerts.quick_languages(tmp_path / "ruleset") == {"php"}, "a ruleset added for a language covers it" - (rules / "rules.toml").write_text(f'[[rule]]\n{rule}status = "disabled"\n') - assert alerts.quick_languages(tmp_path / "ruleset") == set(), "a disabled rule fires nowhere" - - -def test_alerts_on_an_uncovered_language_record_coverage_none_not_a_clean_zero(tmp_path: Path) -> None: - for side in ("v", "f"): - (tmp_path / side).mkdir() - (tmp_path / side / "app.rb").write_text('id = params["id"]\nDB.execute("SELECT " + id)\n') - (tmp_path / side / "app.js").write_text("module.exports = 1;\n") - out = tmp_path / "out" - env = { - "OUSAST_OUTPUT_DIR": str(out), "OUSAST_WORKSPACE_DIR": str(tmp_path / "v"), "OUSAST_FIXED_DIR": str(tmp_path / "f"), - "OUSAST_VULNERABLE_PIN": VULNERABLE, "OUSAST_FIXED_PIN": FIXED, - } # fmt: skip - assert alerts.main(env) == 0 - summary = json.loads((out / "summary.json").read_text()) - assert (out / "alerts.jsonl").read_text() == "" and summary["alerts"] == {"vulnerable": 0, "fixed": 0} - assert summary["coverage"] == { - "javascript": {"coverage": "quick", "files": {"fixed": 1, "vulnerable": 1}}, - "ruby": {"coverage": "none", "files": {"fixed": 1, "vulnerable": 1}}, - } - assert summary["uncovered"] == ["ruby"] and summary["pins"] == {"vulnerable": VULNERABLE, "fixed": FIXED} - - -def test_php_is_quick_covered_and_alerts_on_a_php_sink(tmp_path: Path) -> None: - for side in ("v", "f"): - (tmp_path / side).mkdir() - (tmp_path / side / "index.php").write_text(' None: - repo = tmp_path / "r" - repo.mkdir() - git = ["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t"] - subprocess.run([*git, "init", "-q"], check=True) - (repo / "a.py").write_text("a\nb\nc\n") - subprocess.run([*git, "add", "."], check=True) - subprocess.run([*git, "commit", "-qm", "v"], check=True) - old = subprocess.run([*git, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() - (repo / "a.py").write_text("a\nB1\nB2\nB3\nc\n") - subprocess.run([*git, "commit", "-qam", "f"], check=True) - new = subprocess.run([*git, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() - assert fix_ranges(repo, old, new) == {"a.py": [(2, 2)]} - assert fix_ranges(repo, old, new, side="new") == {"a.py": [(2, 4)]} - - -# --- the generator ------------------------------------------------------------------------------------------------- - - -def test_every_case_ends_in_remember_and_the_loop_is_model_free(tmp_path: Path) -> None: - plain = render([_case()], _templates(), "validation-46", "t", population="population-v2", split="validation") - run, _ = reconciler.load_run(_write(tmp_path / "plain", plain)) - assert [e.name.removeprefix(f"{CASE}-") for e in run.tasks] == [*CHAIN[:4], "vc", "final", "features", "remember"] - remember = run.task(f"{CASE}-remember") - assert remember.inputs["agreed"] == f"{CASE}-final/agreed.json" and "alerts" not in remember.inputs - assert set(remember.producers) == {f"{CASE}-{step}" for step in ("facts", "va", "vb", "vc", "final", "features")} - assert set(run.task(f"{CASE}-features").producers) == {f"{CASE}-{step}" for step in ("facts", "va", "vb", "vc", "final")} - - files = render([_case(fixed_ranges={"x.py": [(1, 2)]})], _templates(), "validation-46", "t", population="p", split="s", loop=LOOP) - run, manifests = reconciler.load_run(_write(tmp_path / "loop", files)) - names = [e.name for e in run.tasks] - assert names[:6] == [f"{CASE}-{step}" for step in CHAIN] - assert names[6:] == [f"{CASE}-alerts", f"{CASE}-features", f"{CASE}-remember", *LOOP_TASKS] - assert run.task(f"{CASE}-features").inputs["alerts"] == f"{CASE}-alerts/alerts.jsonl", "features read the alerts" - assert run.task(f"{CASE}-remember").inputs["alerts"] == f"{CASE}-alerts/alerts.jsonl" - assert run.task(f"{CASE}-remember").inputs["alerts_summary"] == f"{CASE}-alerts/summary.json", "the coverage travels too" - measure_inputs = {f"remember_{CASE}": f"{CASE}-remember/memory.jsonl", "snapshot": "memory-snapshot/rows.jsonl"} - assert run.task("loop-measure").inputs == measure_inputs - assert run.task("loop-improve").producers == ("loop-propose",) - assert run.task("loop-propose").producers == ("loop-measure", "memory-snapshot") - assert run.task(f"{CASE}-alerts").serialize == run.task("loop-improve").serialize == "engine" - fixed = manifests.workspaces[f"{CASE}-fixed"] - assert fixed.pins == {"repo": FIXED} and manifests.workspaces["openultrasast-cccccccccccc"].pins == {"repo": "c" * 40} - for entry in run.tasks[6:]: - task = manifests.tasks[entry.task] - assert entry.budget is not None and (entry.budget.usd, entry.budget.calls) == (0, 0), entry.name - assert reconciler.MODEL_ANNOTATION not in task.metadata.annotations, f"{entry.name} binds no Model" - docs = reconciler.render_task(run, entry, manifests, "http://127.0.0.1:1/") - env = {e["name"]: e["value"] for e in docs[-1]["spec"]["env"]} - assert "OUSAST_MODEL" not in env and float(env["OUSAST_BUDGET_USD"]) == 0 and env["OUSAST_BUDGET_CALLS"] == "0" - guard = json.loads(manifests.tasks["memory-snapshot"].metadata.annotations[SNAPSHOT_ANNOTATION]) - assert guard == {"catalog": "catalog.toml", "manifest": "bench.toml", "populations": []} - inputs = yaml.safe_load((tmp_path / "loop" / "workspaces" / f"{CASE}-inputs.yaml").read_text()) - case_json = json.loads(next(f["content"] for f in inputs["spec"]["files"] if f["path"] == "case.json")) - assert case_json["fixed_ranges"] == {"x.py": [[1, 2]]} - - -# --- the whole loop through the fake ax ---------------------------------------------------------------------------- - - -def _project(root: Path) -> Path: - """This repository at the commit under test, cut down: a benchmark whose one expected finding the eval rule - makes, and an empty pair catalog.""" - (root / "target").mkdir(parents=True) - (root / "target" / "app.py").write_text("def f(x):\n return eval(x)\n") - (root / "bench.toml").write_text( - 'name = "t"\nlanguage = "python_web"\n\n[source]\npath = "target"\n\n[[expected]]\ncwe = "CWE-95"\n' - f'class = "code injection"\npath = "app.py"\nline = 2\nrule_id = "{EVAL}"\nsink = "eval"\nevidence = "eval"\n' - ) - (root / "catalog.toml").write_text("") - (root / "benchmarks" / "pairs").mkdir(parents=True) # the catalog loader's mechanism vocabulary, by relative path - shutil.copy(PLANE.parent / "benchmarks" / "pairs" / "mechanisms.toml", root / "benchmarks" / "pairs") - return root - - -def _older_run(store: FileStore) -> None: - """Two false alerts of the eval rule at rejected candidates of another repository, from an earlier run.""" - rows = [] - for fn in ("f1", "f2"): - base = {"repo": "example.com/other/app", "pin": OTHER, "run": "older", "task": "app-remember", "population": "p", "split": "s"} - base["image"] = "sha256:" + "0" * 64 - cand = f"app.py::{fn}" - rows.append({**base, "id": row_id("verdict", "older", "app-remember", cand), "kind": "verdict", "candidate": cand, - "family": "code_injection", "final": "rejected", "site_match": False}) # fmt: skip - rows.append({**base, "id": row_id("alert", "older", "app-remember", fn), "kind": "alert", "rule_id": EVAL, - "rule_status": "enabled", "path": "app.py", "line": 3, "function": fn, "pin_role": "vulnerable"}) # fmt: skip - store.ingest_rows("older", "app-remember", rows) - - -def test_the_loop_runs_as_one_plane_run_and_the_gate_rejects_a_demotion_that_costs_recall( - fake: Fake, # noqa: F811 - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - project = _project(tmp_path / "project") - monkeypatch.chdir(project) # `ousast plane run` from the repository root: the snapshot's guard paths resolve here - store = FileStore(tmp_path / "memory") - _older_run(store) - monkeypatch.setenv("OUSAST_MEMORY", f"file://{store.root}") - vulnerable, fixed = tmp_path / "checkout-v", tmp_path / "checkout-f" - for root, body in ((vulnerable, "def load(x):\n return x\n"), (fixed, "def load(x):\n return eval(x)\n")): - (root / "lmdeploy" / "vl" / "media").mkdir(parents=True) - (root / "lmdeploy" / "vl" / "media" / "other.py").write_text(body) - case = _case(ranges={"lmdeploy/vl/media/other.py": [(1, 2)]}, fixed_ranges={"lmdeploy/vl/media/other.py": [(1, 2)]}) - files = render([case], _templates(), "validation-46", "t", population="population-v2", split="validation", loop=LOOP) - manifest = _write(tmp_path / "plane", files) - inputs = yaml.safe_load((tmp_path / "plane" / "workspaces" / f"{CASE}-inputs.yaml").read_text()) - (tmp_path / "case.json").write_text(next(f["content"] for f in inputs["spec"]["files"] if f["path"] == "case.json")) - - base = reconciler.run_dir("validation-46") # the recorded chain, done: the loop reuses it and runs only itself - shutil.copytree(FIXTURE, base) - state = {"run": "validation-46", "tasks": {f"{CASE}-{s}": {"status": "done"} for s in CHAIN}} - (base / "state.json").write_text(json.dumps(state)) - - project_env = {"PYTHONPATH": SRC, "OUSAST_PROJECT_DIR": str(project)} - fake.script({ - f"{CASE}-alerts": {"exec": {"env": {"PYTHONPATH": SRC, "OUSAST_WORKSPACE_DIR": str(vulnerable), "OUSAST_FIXED_DIR": str(fixed), - "OUSAST_INPUT_CASE": str(tmp_path / "case.json")}}}, - f"{CASE}-features": {"exec": {"env": {"PYTHONPATH": SRC, "OUSAST_WORKSPACE_DIR": str(vulnerable)}}}, - f"{CASE}-remember": {"exec": {"env": {"PYTHONPATH": SRC}}}, - "loop-measure": {"exec": {"env": {"PYTHONPATH": SRC}}}, - "loop-propose": {"exec": {"env": project_env}}, - "loop-improve": {"exec": {"env": project_env}}, - }) # fmt: skip - - assert cli.main(["plane", "run", str(manifest), "--ax", str(fake.ax)]) == 0, (fake.root / "ax.log").read_text()[-3000:] - - statuses = {name: info["status"] for name, info in json.loads((base / "state.json").read_text())["tasks"].items()} - assert set(statuses.values()) == {"done"} and len(statuses) == 13 - assert json.loads((base / "memory-snapshot" / "index.json").read_text())["kept"] == 4, "seeded on the host, not run" - delivered = [name for _, name in fake.events("delivered")] - assert delivered == [f"{CASE}-alerts", f"{CASE}-features", f"{CASE}-remember", "loop-measure", "loop-propose", "loop-improve"] - - measure = json.loads((base / "loop-measure" / "measure.json").read_text()) - assert measure["run_metrics"]["declared_sites_agreed"] == 1 and measure["run_metrics"]["candidates"] == 1 - assert measure["run_metrics"]["alerts"] == {EVAL: {"vulnerable": 0, "fixed": 1, "fixed_in_fix_range": 1}} - assert measure["union_metrics"]["rejected"] == 2 and measure["rows"]["snapshot"] == 4 - - [proposal] = [json.loads(line) for line in (base / "loop-propose" / "proposals.jsonl").read_text().splitlines()] - assert proposal["edit"]["rule_id"] == EVAL and (proposal["edit"]["from_status"], proposal["edit"]["to_status"]) == ("enabled", "shadow") - assert proposal["provenance"]["counts"] == {"false_alerts": 3, "repos": 2, "agreed_hits": 0} - cited = {(e["kind"], e["repo"], e["pin"]) for e in proposal["provenance"]["evidence"]} - assert ("alert", "example.com/reserved/lmdeploy", FIXED) in cited, "the fixed-pin alert inside the fix is evidence" - assert (base / "loop-propose" / "memory_proposals.jsonl").read_text().count("\n") == 1 - - gate = json.loads((base / "loop-improve" / "gate.json").read_text()) - assert gate["memory_edits"] == [f"rule:{EVAL}:enabled->shadow"] and gate["accepted"] is False and gate["reason"] == "reverted" - assert gate["outcome"]["recall_after"] < gate["outcome"]["recall_before"], "demoting the eval rule loses the benchmark's finding" - assert not (base / "loop-improve" / "rule_policy.json").exists(), "a ledger only when the gate accepts" - assert json.loads((base / "loop-improve" / "journal.json").read_text())[0]["outcome"] == "reverted" - assert not (project / "target" / ".openultrasast").exists(), "the repository's own ledger is never touched" - - report = reconciler.attribution("validation-46") - for row in report["tasks"]: - if row["task"] in (f"{CASE}-alerts", f"{CASE}-features", f"{CASE}-remember", *LOOP_TASKS): - assert row["model"] is None and row["usd"] in (0, None) and row["calls"] in (0, None), row - ingested = FileStore(store.root) - outcomes = ingested.rows(kind="proposal_outcome") - assert {r.row["outcome"] for r in outcomes} == {"reverted"} and len(outcomes) == 2, "one per (repo, pin) of the evidence" - [fixed_alert] = ingested.rows(repo="example.com/reserved/lmdeploy", pin=FIXED, kind="alert") - features = {r.row["candidate"]: r.row for r in ingested.rows(kind="features")} - assert set(features) == {"lmdeploy/vl/media/connection.py::_load_http_url"}, "no fixed-pin alert became a candidate" - assert all(r["pin"] == VULNERABLE and "in_fix_range" not in json.dumps(r["x"]) for r in features.values()) - assert fixed_alert.row["in_fix_range"] is True and fixed_alert.row["function"] == "load" diff --git a/tests/test_subtraction_complete.py b/tests/test_subtraction_complete.py index 338e79d..4458a54 100644 --- a/tests/test_subtraction_complete.py +++ b/tests/test_subtraction_complete.py @@ -64,32 +64,6 @@ def test_the_removed_modules_are_gone_from_the_tree() -> None: assert not (SRC / relative).exists(), f"{relative} still exists" -def test_the_mechanism_lever_is_gone_from_the_retained_improve_loop() -> None: - """`improve/` survives with its lever removed, not deleted. - - The design's Removed list named `improve/evolve.py` whole, but the file is a mix: the mechanism lever - (deleted here) and the deterministic rule-status loop that `ousast improve` still runs from benchmark - evidence, which has no LLM in it and is not what this feature supersedes. Deleting the file would have - taken a retained capability with it, so the lever is stripped and the loop stays — asserted by symbol so - the lever cannot creep back. - """ - evolve = (SRC / "improve" / "evolve.py").read_text() - validator = (SRC / "improve" / "validator.py").read_text() - for symbol in ( - "MechanismEdit", - "MechanismStore", - "apply_mechanism_edits", - "evaluate_mechanism_profiles", - "propose_mechanism_edits", - "validate_mechanism", - "mechanism_store", - "mechanism_candidates", - ): - assert symbol not in evolve, f"{symbol} survives in improve/evolve.py" - assert symbol not in validator, f"{symbol} survives in improve/validator.py" - assert "def run_improvement(" in evolve and "def propose_status_edits(" in evolve, "the rule-status loop must survive" - - def test_the_re_homed_modules_are_where_the_model_layer_expects_them() -> None: for relative in ("model/taxonomy.py", "model/candidates.py", "model/endpoint.py", "model/specs.py"): assert (SRC / relative).is_file(), f"{relative} is missing"