mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-19 04:59:28 +00:00
unseen: durable decision bank in ousast-corpus (stream + resume, retires the local journal)
The draw lost all progress on any restart because the local journal
invalidates on a code change and is the only cross-run state. Make the
bucket the source of truth: stream every per-repo accept/reject to
ousast-corpus as it is decided, keyed by sha256(repository+"\n"+fix) so
writes are idempotent (content dedup at rest), and resume by listing the
bucket and skipping already-decided repos. No supervisor/watchdog needed
-- a crash or a code change just re-runs and resumes.
New benchmarks/unseen/bank.py: corpus_settings (CORPUS_S3_* from .env,
missing key named, value never printed), CorpusObjects (lazy boto3,
path-style + SigV4 verified live against ousast-corpus @ 10.0.0.54:9001),
CorpusBank.bank/decided with one-retry put/get, split_decided. Decisions
live at <prefix>/<seed>/decisions/<key>.json with repository, fix,
accept/reject, reason, entry, exclusions, counts, contract, seed, ts.
draft.py: --bank [prefix] opens the bank on the coordinator (never shipped
to fork workers); run() resumes from bank.decided() before extracting,
reusing accepted entries across contracts (deterministic from repo+commit)
and rejections only when contract == EXTRACTION_CONTRACT ("v1", in
extract.py); collect() streams each decision one-by-one. Seed-order
acceptance, fork/used-set dedup, and duplicate-advisory reuse preserved.
Without --bank, local-journal behavior is unchanged.
CorpusBank proven live end-to-end (bank accept+reject, idempotent re-bank,
decided/split roundtrip). 325 tests pass offline, ruff clean.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
95bed7005c
commit
4aa612253a
@@ -0,0 +1,160 @@
|
||||
"""Durable, content-addressed draw decisions; object I/O stays on the coordinator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
CORPUS_KEYS = ("CORPUS_S3_ENDPOINT", "CORPUS_S3_BUCKET", "CORPUS_AWS_ACCESS_KEY_ID", "CORPUS_AWS_SECRET_ACCESS_KEY")
|
||||
|
||||
|
||||
def corpus_settings(environ: Mapping[str, str] | None = None) -> dict:
|
||||
"""Read corpus-only credentials, naming missing settings without exposing values."""
|
||||
if environ is None:
|
||||
from openultrasast.config import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
environ = os.environ
|
||||
missing = [key for key in CORPUS_KEYS if not environ.get(key)]
|
||||
if missing:
|
||||
raise ValueError(f"Corpus bank needs {', '.join(missing)} in .env or the environment")
|
||||
endpoint = environ["CORPUS_S3_ENDPOINT"].strip()
|
||||
try:
|
||||
parsed = urlsplit(endpoint)
|
||||
valid_endpoint = parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
except ValueError:
|
||||
valid_endpoint = False
|
||||
if not valid_endpoint:
|
||||
raise ValueError("CORPUS_S3_ENDPOINT must be a URL with a scheme (https://host[:port])")
|
||||
settings = {
|
||||
"endpoint": endpoint,
|
||||
"bucket": environ["CORPUS_S3_BUCKET"],
|
||||
"access_key": environ["CORPUS_AWS_ACCESS_KEY_ID"],
|
||||
"secret_key": environ["CORPUS_AWS_SECRET_ACCESS_KEY"],
|
||||
}
|
||||
if environ.get("CORPUS_S3_REGION"):
|
||||
settings["region"] = environ["CORPUS_S3_REGION"]
|
||||
return settings
|
||||
|
||||
|
||||
class CorpusObjects:
|
||||
"""Plain S3 put/get/list, with no bucket administration or capability probes."""
|
||||
|
||||
def __init__(self, *, endpoint: str, bucket: str, access_key: str, secret_key: str, region: str | None = None):
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
self.bucket = bucket
|
||||
self.client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=access_key,
|
||||
aws_secret_access_key=secret_key,
|
||||
region_name=region or "us-east-1",
|
||||
# RustFS/MinIO at an IP endpoint needs path-style addressing
|
||||
# (virtual-host bucket.<ip> does not resolve) and SigV4. Verified
|
||||
# against ousast-corpus @ 10.0.0.54:9001.
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path"},
|
||||
retries={"total_max_attempts": 1},
|
||||
),
|
||||
)
|
||||
|
||||
def get(self, key: str) -> tuple[bytes, str] | None:
|
||||
try:
|
||||
result = self.client.get_object(Bucket=self.bucket, Key=key)
|
||||
except Exception as error:
|
||||
if getattr(error, "response", {}).get("Error", {}).get("Code") in {"NoSuchKey", "404", "NotFound"}:
|
||||
return None
|
||||
raise
|
||||
body = result["Body"]
|
||||
try:
|
||||
return body.read(), result.get("VersionId") or result.get("ETag", "")
|
||||
finally:
|
||||
body.close()
|
||||
|
||||
def put(self, key: str, data: bytes, labels: dict) -> None:
|
||||
self.client.put_object(
|
||||
Bucket=self.bucket,
|
||||
Key=key,
|
||||
Body=data,
|
||||
ContentType="application/json",
|
||||
Tagging=urlencode(labels),
|
||||
)
|
||||
|
||||
def keys(self, prefix: str) -> list[str]:
|
||||
pages = self.client.get_paginator("list_objects_v2").paginate(Bucket=self.bucket, Prefix=prefix)
|
||||
return [item["Key"] for page in pages for item in page.get("Contents", [])]
|
||||
|
||||
|
||||
def _retry(operation, *args, **kwargs):
|
||||
"""Retry one object operation once; a second failure is fatal to durability."""
|
||||
try:
|
||||
return operation(*args, **kwargs)
|
||||
except Exception:
|
||||
return operation(*args, **kwargs)
|
||||
|
||||
|
||||
class CorpusBank:
|
||||
def __init__(self, client, *, seed: int, prefix: str = "unseen"):
|
||||
self.client = client
|
||||
self.seed = seed
|
||||
self.decisions_prefix = f"{prefix}/{seed}/decisions/"
|
||||
|
||||
@staticmethod
|
||||
def key_for(repository: str, fix: str) -> str:
|
||||
return hashlib.sha256(f"{repository}\n{fix}".encode()).hexdigest()
|
||||
|
||||
def bank(self, repository: str, fix: str, result: dict, *, contract: str) -> None:
|
||||
decision = {
|
||||
"repository": repository,
|
||||
"fix": fix,
|
||||
"decision": "accept" if "entry" in result else "reject",
|
||||
"reason": result.get("rejected"),
|
||||
"entry": result.get("entry"),
|
||||
"ordinary_exclusions": result.get("ordinary_exclusions", {}),
|
||||
"counts": result.get("counts", {}),
|
||||
"contract": contract,
|
||||
"seed": self.seed,
|
||||
}
|
||||
key = self.decisions_prefix + self.key_for(repository, fix) + ".json"
|
||||
existing = _retry(self.client.get, key)
|
||||
if existing is not None:
|
||||
previous = json.loads(existing[0])
|
||||
if {field: value for field, value in previous.items() if field != "ts"} == decision:
|
||||
return # Preserve the first timestamp and avoid an unnecessary object version.
|
||||
decision["ts"] = datetime.now(UTC).isoformat()
|
||||
data = json.dumps(decision, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
_retry(self.client.put, key, data, labels={"decision": decision["decision"], "contract": contract})
|
||||
|
||||
def decided(self) -> dict[str, dict]:
|
||||
decisions = {}
|
||||
for key in sorted(self.client.keys(self.decisions_prefix)):
|
||||
stored = _retry(self.client.get, key)
|
||||
if stored is None:
|
||||
raise ValueError("corpus_decision_missing")
|
||||
decision = json.loads(stored[0])
|
||||
digest = self.key_for(decision["repository"], decision["fix"])
|
||||
if key != self.decisions_prefix + digest + ".json" or decision["seed"] != self.seed:
|
||||
raise ValueError("corpus_decision_identity")
|
||||
if decision["decision"] not in {"accept", "reject"}:
|
||||
raise ValueError("corpus_decision_invalid")
|
||||
decisions[digest] = decision
|
||||
return decisions
|
||||
|
||||
|
||||
def split_decided(decisions: Mapping[str, dict], *, contract: str) -> tuple[list[dict], set[str]]:
|
||||
"""Split reusable results, sorted independently of bucket listing order.
|
||||
|
||||
The coordinator merges entries into the full advisory population before seeded
|
||||
ordering: shuffling only the accepted subset would change the draw's order.
|
||||
"""
|
||||
accepted = [row["entry"] for row in decisions.values() if row["decision"] == "accept"]
|
||||
accepted.sort(key=lambda entry: (entry.get("advisory", ""), entry["repository"], entry["fix"]))
|
||||
reusable = {key for key, row in decisions.items() if row["decision"] == "accept" or row["contract"] == contract}
|
||||
return accepted, reusable
|
||||
Reference in New Issue
Block a user