mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
feat(corpus-seeded-mechanisms): the mechanisms lever runs from ousast improve
Review rounds 3-5 remediations (Req 5, 7). - ousast improve resolves candidates from the target's calibration dir, then the working directory's (where `mechanisms export` writes), and admits into <target>/.openultrasast/calibration/mechanisms.jsonl, where a scan of that target reads; --mechanism-candidates, --mechanism-store, --no-mechanisms; --dry-run redirects the store to scratch; accepted rounds print admit/retract <id>. - run_improvement threads the store paths; RoundOutcome carries the mechanism edits; reverted mechanism keys are blocked outright; a no-gain admission is rejected as mechanism_no_gain; a retraction may name an admitted record that left the candidate set. - leave-one-out forwards variant-search degradations and pins the containment and label rules; mechanisms export defaults to the candidates file so it never re-admits a tombstone. - design §Lever rewritten to the implemented gate; README and docs show export -> improve -> scan. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
624024e72a
commit
b3b47ad757
@@ -148,6 +148,19 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"--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(
|
||||
"--mechanism-candidates",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="exporter candidates the mechanisms lever may admit (default: <target>/.openultrasast/calibration/mechanism-candidates.jsonl, then ./.openultrasast/calibration/mechanism-candidates.jsonl)",
|
||||
)
|
||||
improve.add_argument(
|
||||
"--mechanism-store",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="scan-time mechanisms.jsonl the lever writes (default: <target>/.openultrasast/calibration/mechanisms.jsonl)",
|
||||
)
|
||||
improve.add_argument("--no-mechanisms", action="store_true", help="disable the mechanisms lever for this run")
|
||||
|
||||
pairs = subparsers.add_parser(
|
||||
"pairs",
|
||||
@@ -175,7 +188,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
export.add_argument("--slice", choices=SLICE_NAMES, default="all")
|
||||
export.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG)
|
||||
export.add_argument(
|
||||
"--store", type=Path, default=Path(CALIBRATION_DIR) / "mechanisms.jsonl", help="append-only mechanisms.jsonl the scan reads"
|
||||
"--store",
|
||||
type=Path,
|
||||
default=Path(CALIBRATION_DIR) / "mechanism-candidates.jsonl",
|
||||
help="append-only candidates file the improve lever admits from (the scan reads mechanisms.jsonl next to it; never written here)",
|
||||
)
|
||||
export.add_argument("--json", action="store_true")
|
||||
|
||||
@@ -199,6 +215,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
pair_catalog=None if args.no_pair_gate else args.pair_catalog,
|
||||
profile_tolerance=args.profile_tolerance,
|
||||
min_holdout_pairs=args.min_holdout_pairs,
|
||||
mechanism_candidates=args.mechanism_candidates,
|
||||
mechanism_store=args.mechanism_store,
|
||||
mechanisms_enabled=not args.no_mechanisms,
|
||||
)
|
||||
if args.command == "pairs":
|
||||
return _pairs(
|
||||
@@ -784,6 +803,9 @@ def _improve(
|
||||
pair_catalog: Path | None = None,
|
||||
profile_tolerance: float = 0.0,
|
||||
min_holdout_pairs: int = 5,
|
||||
mechanism_candidates: Path | None = None,
|
||||
mechanism_store: Path | None = None,
|
||||
mechanisms_enabled: bool = True,
|
||||
) -> int:
|
||||
if not manifest_path.exists() or not manifest_path.is_file():
|
||||
raise SystemExit(f"benchmark manifest is not a file: {manifest_path}")
|
||||
@@ -794,6 +816,19 @@ def _improve(
|
||||
pair_cases = select_split(load_pair_catalog(pair_catalog), "holdout")
|
||||
manifest = load_benchmark_manifest(manifest_path)
|
||||
target = resolve_benchmark_source(manifest_path, manifest)
|
||||
# corpus-seeded-mechanisms Req 5: the lever runs when a candidates file exists (exporter output) and the pair gate is on.
|
||||
# Candidates: an explicit path, else the target's calibration dir, else the working directory's (where `mechanisms export`
|
||||
# writes by default). The store always lives where a scan of this target reads it unless overridden.
|
||||
calibration = target / CALIBRATION_DIR
|
||||
candidates_path: Path | None = mechanism_candidates
|
||||
if candidates_path is None:
|
||||
for candidate in (calibration / "mechanism-candidates.jsonl", Path(CALIBRATION_DIR) / "mechanism-candidates.jsonl"):
|
||||
if candidate.is_file():
|
||||
candidates_path = candidate
|
||||
break
|
||||
if not mechanisms_enabled or not pair_cases or candidates_path is None or not candidates_path.is_file():
|
||||
candidates_path = None
|
||||
store_path = mechanism_store if mechanism_store is not None else calibration / "mechanisms.jsonl"
|
||||
policy = load_policy()
|
||||
|
||||
# The loop writes its accepted ledger where `scan`/`benchmark` read it, so a
|
||||
@@ -803,6 +838,11 @@ def _improve(
|
||||
if dry_run:
|
||||
ledger_path = Path(scratch) / "rule_policy.json"
|
||||
journal_path = Path(scratch) / "improve_journal.json"
|
||||
if candidates_path is not None: # a dry run must not mutate the scan-time store either
|
||||
scratch_store = Path(scratch) / "mechanisms.jsonl"
|
||||
if store_path.is_file():
|
||||
scratch_store.write_bytes(store_path.read_bytes())
|
||||
store_path = scratch_store
|
||||
else:
|
||||
ledger_path = ledger or default_ledger
|
||||
journal_path = journal or ledger_path.with_name("improve_journal.json")
|
||||
@@ -820,6 +860,8 @@ def _improve(
|
||||
pair_cases=pair_cases,
|
||||
profile_tolerance=profile_tolerance,
|
||||
min_holdout_pairs=min_holdout_pairs,
|
||||
mechanism_candidates=candidates_path,
|
||||
mechanism_store=store_path if candidates_path is not None else None,
|
||||
)
|
||||
_print_improve_outcomes(outcomes, manifest_path, target, ledger_path, dry_run=dry_run)
|
||||
return 0
|
||||
@@ -951,7 +993,13 @@ def _print_improve_outcomes(outcomes: list[RoundOutcome], manifest_path: Path, t
|
||||
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 "-"
|
||||
edits = (
|
||||
", ".join(
|
||||
[f"{e.rule_id}:{e.from_status}->{e.to_status}" for e in outcome.edits]
|
||||
+ [f"{m.action} {m.mechanism_id}" for m in outcome.mechanism_edits]
|
||||
)
|
||||
or "-"
|
||||
)
|
||||
print(
|
||||
f"round {outcome.round}: {outcome.reason} | "
|
||||
f"recall {outcome.recall_before:.2%}->{outcome.recall_after:.2%} "
|
||||
@@ -962,7 +1010,7 @@ def _print_improve_outcomes(outcomes: list[RoundOutcome], manifest_path: Path, t
|
||||
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 written)")
|
||||
print("dry_run=true (no ledger or mechanism store written)")
|
||||
elif accepted:
|
||||
print(f"ledger={ledger_path}")
|
||||
else:
|
||||
|
||||
@@ -292,10 +292,13 @@ def run_round(
|
||||
try:
|
||||
for edit in edits:
|
||||
validator.validate(edit, ruleset_by_id, policy)
|
||||
if mechanism_edits and candidates_store is not None:
|
||||
if mechanism_edits and candidates_store is not None and mechanism_store is not None:
|
||||
from ..semantic.mechanisms import MechanismStore
|
||||
|
||||
by_id = {record.id: record for record in candidates_store.load()}
|
||||
admitted_by_id = {record.id: record for record in MechanismStore(mechanism_store).load()}
|
||||
for mechanism_edit in mechanism_edits:
|
||||
validator.validate_mechanism(mechanism_edit, by_id)
|
||||
validator.validate_mechanism(mechanism_edit, by_id, admitted=admitted_by_id)
|
||||
except StrictValidationError as exc:
|
||||
_record(journal_path, round_index, edits, "rejected", before, before, str(exc), mechanism_edits=mechanism_edits)
|
||||
return _outcome(
|
||||
@@ -416,6 +419,8 @@ def run_improvement(
|
||||
pair_cases: Sequence[object] | None = None,
|
||||
profile_tolerance: float = 0.0,
|
||||
min_holdout_pairs: int = 5,
|
||||
mechanism_candidates: Path | None = None,
|
||||
mechanism_store: Path | None = None,
|
||||
) -> list[RoundOutcome]:
|
||||
"""Run improvement rounds until convergence (no new proposals) or ``max_rounds``."""
|
||||
outcomes: list[RoundOutcome] = []
|
||||
@@ -432,6 +437,8 @@ def run_improvement(
|
||||
pair_cases=pair_cases,
|
||||
profile_tolerance=profile_tolerance,
|
||||
min_holdout_pairs=min_holdout_pairs,
|
||||
mechanism_candidates=mechanism_candidates,
|
||||
mechanism_store=mechanism_store,
|
||||
)
|
||||
outcomes.append(outcome)
|
||||
if outcome.reason == "no_proposals":
|
||||
@@ -507,6 +514,7 @@ def _outcome(
|
||||
accepted=accepted,
|
||||
reason=reason,
|
||||
edits=edits,
|
||||
mechanism_edits=list(mechanism_edits or []),
|
||||
recall_before=before.recall,
|
||||
recall_after=after.recall,
|
||||
fp_before=before.fp_rate,
|
||||
|
||||
@@ -31,7 +31,9 @@ def reverted_edit_keys(rounds: list[dict[str, object]]) -> set[str]:
|
||||
continue
|
||||
for edit in _edits(entry):
|
||||
key = edit.get("key")
|
||||
if isinstance(key, str) and not edit.get("rationale"):
|
||||
# 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
|
||||
|
||||
|
||||
@@ -118,13 +118,20 @@ class EvolveValidator:
|
||||
else:
|
||||
raise StrictValidationError(f"unknown edit type {type(edit).__name__}", kind="rule_change")
|
||||
|
||||
def validate_mechanism(self, edit: MechanismEdit, candidates: Mapping[str, object]) -> None:
|
||||
"""Req 5.2: only records the exporter derived from trusted pairs, with a closed guard and identifier-only shape text."""
|
||||
def validate_mechanism(
|
||||
self, edit: MechanismEdit, candidates: Mapping[str, object], admitted: Mapping[str, object] | None = None
|
||||
) -> None:
|
||||
"""Req 5.2: only records the exporter derived from trusted pairs, with a closed guard and identifier-only shape text.
|
||||
|
||||
A retraction may also name a record that is admitted in the scan store but no longer in the candidate set.
|
||||
"""
|
||||
from ..semantic.variants import GUARD_KINDS, SOURCE_KINDS
|
||||
|
||||
if edit.action not in MECHANISM_ACTIONS:
|
||||
raise StrictValidationError(f"mechanism edit action {edit.action!r} is not admit or retract", kind="mechanism_change")
|
||||
record = candidates.get(edit.mechanism_id)
|
||||
if record is None and edit.action == "retract" and admitted is not None:
|
||||
record = admitted.get(edit.mechanism_id)
|
||||
if record is None:
|
||||
raise StrictValidationError(
|
||||
f"unknown mechanism {edit.mechanism_id!r}: not in the exporter candidate set", kind="mechanism_change"
|
||||
|
||||
@@ -36,6 +36,7 @@ class LooOutcome:
|
||||
hits_fixed: int
|
||||
teaches: bool # the pair contributed at least one shape when others were held out
|
||||
leaked_by: tuple[str, ...] = () # mechanism record ids that hit the fixed side
|
||||
degradations: tuple[dict[str, object], ...] = () # from variant search on either side
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
@@ -95,9 +96,22 @@ def evaluate_loo(cases: Sequence[PairCase], *, facts: SemanticFacts | None = Non
|
||||
per_mechanism=_group(outcomes, lambda item: item.mechanisms),
|
||||
skipped=tuple(skipped),
|
||||
teaching_pairs=sum(1 for shapes in lessons.values() if shapes),
|
||||
degradations=_unique_degradations(outcomes),
|
||||
)
|
||||
|
||||
|
||||
def _unique_degradations(outcomes: Sequence[LooOutcome]) -> tuple[dict[str, object], ...]:
|
||||
seen: set[str] = set()
|
||||
unique: list[dict[str, object]] = []
|
||||
for outcome in outcomes:
|
||||
for item in outcome.degradations:
|
||||
key = repr(sorted(item.items()))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(item)
|
||||
return tuple(unique)
|
||||
|
||||
|
||||
def _hold_out(case: PairCase, targets: Sequence[PairCase], lessons: dict[str, list[Lesson]], facts: SemanticFacts) -> LooOutcome:
|
||||
with tempfile.TemporaryDirectory(prefix="ousast-loo-") as scratch:
|
||||
root = Path(scratch)
|
||||
@@ -127,8 +141,9 @@ def score_pair_with_store(case: PairCase, store: MechanismStore, facts: Semantic
|
||||
base = root if root is not None else Path(scratch)
|
||||
vuln_root = _materialize(base / "vuln", case.vuln_file, case.relpath)
|
||||
fix_root = _materialize(base / "fixed", case.fixed_file, case.relpath)
|
||||
vuln_hits = search_tree(vuln_root, _targets(vuln_root), store, facts, max_mechanisms=len(records) or 1).hits
|
||||
fix_hits = search_tree(fix_root, _targets(fix_root), store, facts, max_mechanisms=len(records) or 1).hits
|
||||
vuln_search = search_tree(vuln_root, _targets(vuln_root), store, facts, max_mechanisms=len(records) or 1)
|
||||
fix_search = search_tree(fix_root, _targets(fix_root), store, facts, max_mechanisms=len(records) or 1)
|
||||
vuln_hits, fix_hits = vuln_search.hits, fix_search.hits
|
||||
text = case.vuln_file.read_text(errors="ignore")
|
||||
ranges = named_function_ranges(case.relpath, text, _parse_language(case)) or ()
|
||||
found: list[tuple[str, tuple[str, ...]]] = []
|
||||
@@ -150,6 +165,7 @@ def score_pair_with_store(case: PairCase, store: MechanismStore, facts: Semantic
|
||||
hits_fixed=len(fix_hits),
|
||||
teaches=False,
|
||||
leaked_by=tuple(sorted({hit.mechanism_id for hit in fix_hits})),
|
||||
degradations=tuple(vuln_search.degradations) + tuple(fix_search.degradations),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user