uofa-demo / src /uofa_cli /adversarial /classifier.py
cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
36.1 kB
"""Outcome classifier for adversarial coverage experiments — Phase 2 §10.
`uofa adversarial analyze` reads a batch manifest produced by
:mod:`uofa_cli.adversarial.runner`, runs ``uofa rules`` per generated
package to extract the rule firings, and classifies each package into
one of seven outcome classes per spec §10.3:
COV-HIT target rule fires; no unexpected rules
COV-HIT-PLUS target fires; other rules also fire
COV-MISS target does not fire; no other rules fire
COV-WRONG target does not fire; different rules fire
COV-CLEAN-CORRECT no rules fire (negative_control as desired)
COV-CLEAN-WRONG rules fire on a negative_control (precision bug)
GEN-INVALID package SHACL-failed during generation
Output CSVs (per spec §10.3, §10.4, §11.2):
outcomes.csv per-package row
matrix.csv aggregated catalog × subtlety pivot
summary.csv per-pattern aggregate (one row per shipped UofA core
pattern; schema per UofA_Phase2_M4_Cleanup_Spec.md)
Skeleton-mode generation: each synthetic package is a fresh standalone
generation, not a delta against the underlying base COU. The classifier
therefore reads observed Jena rule firings directly — no baseline
subtraction, no inheritance assumption. ``base_cou_key`` is still
recorded per row so summary.csv can bucket recall by COU (D1, v1.8 §10.4).
Note on summary.csv: View-3 overall precision/recall metrics
(catalog_recall, catalog_precision_1_minus_fpr, gap_probe_miss_rate)
are emitted in the HTML report's View 3 only — they were previously
also written to summary.csv but were moved out so summary.csv can
carry the per-pattern aggregate that the D1 extension spec depends on.
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import socket
import subprocess
import time
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass, field
from pathlib import Path
from uofa_cli.output import error, info, result_line, warn
# Active UofA core patterns at v0.5.4. Source of truth:
# packs/core/rules/uofa_weakener.rules (`uofa catalog` reports the
# same 23 IDs at v0.5.4 HEAD; COMPOUND-02 is commented out and
# excluded). Used as the row index for summary.csv per-pattern aggregate.
_CORE_PATTERN_IDS: tuple[str, ...] = (
"W-AR-01", "W-AR-02", "W-AR-03", "W-AR-04", "W-AR-05",
"W-EP-01", "W-EP-02", "W-EP-03", "W-EP-04",
"W-AL-01", "W-AL-02",
"W-ON-01", "W-ON-02",
"W-SI-01", "W-SI-02",
"W-CON-01", "W-CON-02", "W-CON-03", "W-CON-04", "W-CON-05",
"W-PROV-01",
"COMPOUND-01", "COMPOUND-03",
)
SUMMARY_FIELDS: tuple[str, ...] = (
"pattern_id",
"confirm_existing_count",
"confirm_existing_hits",
"recall",
"negative_control_firings",
"gap_probe_firings",
"total_firings_across_battery",
# D1 (v1.8) per-COU coverage delta columns
"recall_morrison_cou1",
"recall_morrison_cou2",
"recall_nagaraja",
"recall_min_per_cou",
"recall_cou_disparity",
"cou_dependent_flag",
# M5-B (v0.5.7) per-rule precision columns. Address the "0 COV-HIT,
# 100% HIT-PLUS" finding from Apr 27 M5 analyze: when a target rule
# fires, other rules fire too, so package-level outcome class
# conflates per-rule specificity with rule overlap. These columns
# decompose:
# nc_fpr — fraction of negative_control packages where THIS rule
# fired. Direct FPR-driver signal: large values point at
# over-permissive rule patterns deserving §13.3 audit.
# precision_when_fires — across ALL rows where this rule fired
# (any intent), fraction where the spec actually targeted
# this rule (intent==confirm_existing or interaction with
# target_weakener == this rule). Low values mean the rule
# fires on the wrong things; high values mean it's a
# specific, well-targeted detector.
"nc_fpr",
"precision_when_fires",
)
# D1 (v1.8): _detect_baseline_key returns one of these for shipped base COUs.
# These are the column-name suffixes used in summary.csv per-COU columns.
_COU_KEY_TO_COLUMN: dict[str, str] = {
"morrison/cou1": "recall_morrison_cou1",
"morrison/cou2": "recall_morrison_cou2",
"nagaraja/cou1": "recall_nagaraja",
}
# D1 (v1.8): rules with recall disparity >= this threshold across base COUs
# are flagged as COU-dependent. Threshold per Phase 2 Spec v1.8 §10.4.
COU_DEPENDENT_DISPARITY_THRESHOLD: float = 0.30
@dataclass
class _OutcomeRow:
spec_id: str
variant_num: int
target_weakener: str | None
source_taxonomy: str | None
coverage_intent: str
subtlety: str
outcome_class: str
rules_fired: str
target_rule_fired: bool
section_6_7_candidate: str | None
shacl_retries: int
tokens: int
cost_usd: float
# D1 (v1.8): which base COU this row's spec was generated against. Values
# are keys of _COU_KEY_TO_COLUMN ("morrison/cou1" etc.) or None for specs
# whose base_cou doesn't match a shipped COU. Internal-only — not written
# to outcomes.csv (v1.8 §10.3 only adds D2 timing columns).
base_cou_key: str | None = None
# D2 (v1.8) per-package timing capture; see Phase 2 Spec §5.4.
total_eval_ms: int = 0
jena_load_ms: int = 0
jena_inference_ms: int = 0
output_serialize_ms: int = 0
eval_host_id: str = ""
def _detect_baseline_key(base_cou: str | None) -> str | None:
"""Match ``packs/vv40/examples/morrison/cou1`` → ``morrison/cou1``.
Used by D1 per-COU recall bucketing in summary.csv. The matched key
is one of the entries in :data:`_COU_KEY_TO_COLUMN`.
"""
if not base_cou:
return None
s = str(base_cou)
for key in _COU_KEY_TO_COLUMN:
if key in s:
return key
return None
# Pattern matching for `uofa rules` output (one annotation per line).
_RULE_LINE = re.compile(r"^\s*[⚠⚡]\s+(W-[A-Z]+-\d+|COMPOUND-\d+)\s+\[")
_HIT_COUNT = re.compile(r"—\s+(\d+)\s+hit\(s\)")
def _parse_rule_firings_from_check(stdout: str) -> dict[str, int]:
"""Extract ``{pattern_id: hit_count}`` from `uofa check` / `uofa rules` stdout."""
firings: dict[str, int] = {}
for line in stdout.splitlines():
m = _RULE_LINE.search(line)
if not m:
continue
pattern = m.group(1)
hit_match = _HIT_COUNT.search(line)
firings[pattern] = int(hit_match.group(1)) if hit_match else 1
return firings
def _resolve_eval_host_id() -> str:
"""D2 (v1.8) host id: env var override or hostname fallback."""
return os.environ.get("UOFA_EVAL_HOST_ID") or socket.gethostname() or "unknown"
def _run_rules_on_package(
package_path: Path, pack: str = "vv40"
) -> tuple[dict[str, int], dict[str, int]]:
"""Invoke `uofa rules` on a package and return (firings, timings).
timings dict keys: ``total_eval_ms``, ``jena_load_ms``,
``jena_inference_ms``, ``output_serialize_ms``. The Jena split is
best-effort — without Java-side instrumentation we cannot separate
load from inference, so ``jena_load_ms`` is 0 and ``jena_inference_ms``
carries the lumped subprocess cost. ``output_serialize_ms`` is the
Python-side parse time. See Phase 2 Spec v1.8 §5.4 fallback.
Returns ({}, {timings}) on subprocess error so the classifier records
GEN-INVALID without crashing.
"""
timings = {
"total_eval_ms": 0,
"jena_load_ms": 0,
"jena_inference_ms": 0,
"output_serialize_ms": 0,
}
t_start = time.perf_counter_ns()
try:
result = subprocess.run(
["python", "-m", "uofa_cli", "rules", "--pack", pack, str(package_path)],
capture_output=True,
text=True,
timeout=120,
)
except (subprocess.TimeoutExpired, OSError):
timings["total_eval_ms"] = int((time.perf_counter_ns() - t_start) / 1_000_000)
return {}, timings
t_subprocess_end = time.perf_counter_ns()
firings = _parse_rule_firings_from_check(result.stdout)
t_parse_end = time.perf_counter_ns()
subprocess_ms = int((t_subprocess_end - t_start) / 1_000_000)
parse_ms = int((t_parse_end - t_subprocess_end) / 1_000_000)
timings["total_eval_ms"] = subprocess_ms + parse_ms
# Lump subprocess time into jena_inference_ms (see docstring fallback).
timings["jena_inference_ms"] = subprocess_ms
timings["output_serialize_ms"] = parse_ms
return firings, timings
def _classify(
coverage_intent: str,
target_weakener: str | None,
firings: dict[str, int],
package_exists: bool,
) -> tuple[str, bool]:
"""Return ``(outcome_class, target_rule_fired)``.
*firings* is the raw ``{pattern_id: hit_count}`` dict observed by
running the rule engine on the synthetic package. Skeleton-mode
generation produces standalone packages, so observed firings ARE
attributable to the package itself — no baseline subtraction.
"""
if not package_exists:
return "GEN-INVALID", False
fired = set(firings.keys())
target_fired = bool(target_weakener and target_weakener in fired)
if coverage_intent == "negative_control":
if not fired:
return "COV-CLEAN-CORRECT", False
return "COV-CLEAN-WRONG", False
if coverage_intent == "gap_probe":
if not fired:
return "COV-MISS", False
return "COV-WRONG", False
if coverage_intent == "interaction":
# Interaction expects multiple firings; we report HIT-PLUS if any
# rule fires (the spec §13.4 acceptance for INT-1..4 checks
# COMPOUND firings via the per-pattern matrix, not via this label).
if not fired:
return "COV-MISS", False
return "COV-HIT-PLUS", target_fired
# confirm_existing
if target_fired:
if len(fired) == 1:
return "COV-HIT", True
return "COV-HIT-PLUS", True
if not fired:
return "COV-MISS", False
return "COV-WRONG", False
@dataclass
class _PackageWork:
"""One per-variant work item for the parallel Jena phase.
Pre-computed metadata so the Jena phase only needs `package_path` +
`package_exists`; the classification phase reuses everything else
via field name. Held in original iteration order so the final rows
list is identical to the sequential implementation.
"""
spec_id: str
variant_num: int
target_weakener: str | None
source_taxonomy: str | None
coverage_intent: str
subtlety: str
shacl_retries: int
tokens: int
cost_usd: float
base_cou_key: str | None
package_path: Path | None
package_exists: bool
# Filled in by the Jena phase:
firings: dict[str, int] = field(default_factory=dict)
timings: dict[str, int] = field(
default_factory=lambda: {
"total_eval_ms": 0,
"jena_load_ms": 0,
"jena_inference_ms": 0,
"output_serialize_ms": 0,
}
)
def _run_rules_on_work(work: _PackageWork, pack: str) -> _PackageWork:
"""Thread worker: invoke Jena on *work*'s package and stash results.
Returns the same _PackageWork instance with `firings` and `timings`
populated. Returning the work item (rather than a tuple) makes the
caller's `as_completed` reassembly trivial — the work item carries
its own original-order index implicitly via list position.
"""
if work.package_exists and work.package_path is not None:
work.firings, work.timings = _run_rules_on_package(
work.package_path, pack=pack
)
return work
def _scan_outcomes(
in_dir: Path, pack: str, parallel: int = 1
) -> list[_OutcomeRow]:
"""Walk the batch_manifest.perSpecResults and produce per-package rows.
*parallel* controls the Jena concurrency. Default 1 preserves the
sequential behavior. Higher values use a ThreadPoolExecutor over the
per-package `_run_rules_on_package` calls — each spawns its own JVM
subprocess so thread-safety is essentially free. Phase 2 §11 metric
semantics are unchanged: classification + accumulation happen
sequentially in original order after the parallel Jena phase
completes, so outcomes.csv row order is identical to the sequential
implementation.
"""
manifest_path = in_dir / "batch_manifest.json"
if not manifest_path.exists():
raise FileNotFoundError(
f"batch_manifest.json not found in {in_dir}; "
f"run `uofa adversarial run` first"
)
batch = json.loads(manifest_path.read_text())
# D2 (v1.8): single host id per analyze invocation.
eval_host_id = _resolve_eval_host_id()
# Per-rule timing accumulator for rule_timing.csv (D2). Keyed by
# (rule_id, package_path); value is rule_eval_ms. With Jena's native
# per-rule timing unavailable, we record only that the rule fired
# (rule_eval_ms = 0 placeholder) so the CSV is shape-correct for
# downstream consumers. The fallback note in batch_manifest documents
# the limitation.
rule_timing_rows: list[dict] = []
rows: list[_OutcomeRow] = []
# Phase 1: build the work list in original (perSpecResults × variants)
# order. Each item is a _PackageWork carrying everything Phase 2 (Jena)
# and Phase 3 (classify+accumulate) need. Holding original order in
# this list guarantees outcomes.csv row order is preserved when
# parallel > 1.
work_items: list[_PackageWork] = []
for per_spec in batch.get("perSpecResults", []):
spec_id = per_spec["spec_id"]
spec_out_dir = Path(per_spec["out_dir"])
coverage_intent = per_spec["coverage_intent"]
target_weakener = per_spec.get("target_weakener")
source_taxonomy = per_spec.get("source_taxonomy")
per_spec_manifest_path = spec_out_dir / "manifest.json"
if not per_spec_manifest_path.exists():
warn(f" (no per-spec manifest for {spec_id}; skipping)")
continue
per_spec_manifest = json.loads(per_spec_manifest_path.read_text())
# Detect base_cou key from spec_path → base_cou (best effort: read
# spec). Used downstream for D1 per-COU recall bucketing in
# summary.csv. ``base_cou_key`` is None when the spec's base_cou
# doesn't match a shipped COU (e.g., NASA cross-pack specs).
baseline_key = None
try:
spec_path = Path(per_spec["spec_path"])
from uofa_cli.adversarial.spec_loader import load_spec
spec_obj = load_spec(spec_path)
baseline_key = _detect_baseline_key(str(spec_obj.base_cou))
except Exception:
pass
for variant in per_spec_manifest.get("variants", []):
variant_num = variant.get("variantNum") or variant.get("variant_num")
package_path_str = (
variant.get("packagePath") or variant.get("package_path")
)
tokens = variant.get("tokens", 0)
shacl_retries = variant.get("shaclRetries") or variant.get("shacl_retries", 0)
shacl_passed = (
variant.get("shaclPassed", False)
or variant.get("shacl_passed", False)
)
# Generator writes packagePath as a bare filename relative to
# the per-spec output directory. Resolve it.
package_path = None
if package_path_str:
p = Path(package_path_str)
if not p.is_absolute():
p = spec_out_dir / p
package_path = p
package_exists = (
package_path is not None and package_path.exists() and shacl_passed
)
work_items.append(_PackageWork(
spec_id=spec_id,
variant_num=variant_num or 0,
target_weakener=target_weakener,
source_taxonomy=source_taxonomy,
coverage_intent=coverage_intent,
subtlety=per_spec_manifest.get("subtlety", "high"),
shacl_retries=shacl_retries,
tokens=tokens,
cost_usd=variant.get("estimatedCostUsd", 0.0),
base_cou_key=baseline_key,
package_path=package_path,
package_exists=package_exists,
))
# ----- Phase 2: parallel Jena -----
# Each worker calls _run_rules_on_package, which spawns its own JVM
# subprocess. Thread-safety is free — no shared state between workers.
# Default parallel=1 preserves sequential behavior; M5-scale batches
# benefit from parallel=5+ (see m5_findings.md F7).
parallel = max(1, int(parallel or 1))
if parallel == 1 or len(work_items) == 0:
for w in work_items:
_run_rules_on_work(w, pack=pack)
else:
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=parallel) as pool:
# Submit; we don't need as_completed because each worker
# mutates the work item in place (it's the same object the
# main thread will iterate sequentially in Phase 3).
list(pool.map(lambda w: _run_rules_on_work(w, pack=pack), work_items))
# ----- Phase 3: sequential classification + accumulation -----
# Iterates work_items in original order, so outcomes.csv row order
# is identical to the pre-F7 implementation.
for w in work_items:
firings = w.firings
timings = w.timings
# D2: collect per-rule timing rows. Jena native per-rule timing
# is not exposed; record rule_fired only (rule_eval_ms 0).
for pat in firings:
rule_timing_rows.append({
"rule_id": pat,
"package_path": str(w.package_path) if w.package_path else "",
"rule_eval_ms": 0,
"rule_fired": "True",
})
outcome_class, target_fired = _classify(
coverage_intent=w.coverage_intent,
target_weakener=w.target_weakener,
firings=firings,
package_exists=w.package_exists,
)
rules_fired_str = ",".join(sorted(firings.keys()))
rows.append(_OutcomeRow(
spec_id=w.spec_id,
variant_num=w.variant_num,
target_weakener=w.target_weakener,
source_taxonomy=w.source_taxonomy,
coverage_intent=w.coverage_intent,
subtlety=w.subtlety,
outcome_class=outcome_class,
rules_fired=rules_fired_str,
target_rule_fired=target_fired,
section_6_7_candidate=None,
shacl_retries=w.shacl_retries,
tokens=w.tokens,
cost_usd=w.cost_usd,
base_cou_key=w.base_cou_key,
total_eval_ms=timings["total_eval_ms"],
jena_load_ms=timings["jena_load_ms"],
jena_inference_ms=timings["jena_inference_ms"],
output_serialize_ms=timings["output_serialize_ms"],
eval_host_id=eval_host_id,
))
# Stash rule_timing_rows on the function for run_analyze to pick up.
# (Cleaner than rewiring the return tuple; classifier callers within
# the codebase only consume rows.)
_scan_outcomes._last_rule_timing_rows = rule_timing_rows # type: ignore[attr-defined]
return rows
# Internal-only _OutcomeRow fields that are NOT exported to outcomes.csv.
# (D1 v1.8: base_cou_key is internal to the per-COU aggregator; v1.8 §10.3
# does not add a base_cou column to outcomes.csv.)
_OUTCOMES_CSV_EXCLUDED_FIELDS: frozenset[str] = frozenset({"base_cou_key"})
def _write_outcomes_csv(rows: list[_OutcomeRow], out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
f for f in _OutcomeRow.__dataclass_fields__
if f not in _OUTCOMES_CSV_EXCLUDED_FIELDS
]
with open(out_path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
w.writeheader()
for row in rows:
w.writerow(asdict(row))
def _build_matrix(rows: list[_OutcomeRow]) -> dict:
"""Pivot 1: catalog self-coverage = HIT+HIT-PLUS / total per (pattern × subtlety)."""
pivot: dict[tuple[str, str], dict[str, int]] = defaultdict(lambda: {"hit": 0, "total": 0})
for r in rows:
if r.coverage_intent != "confirm_existing" or not r.target_weakener:
continue
key = (r.target_weakener, r.subtlety)
pivot[key]["total"] += 1
if r.outcome_class in ("COV-HIT", "COV-HIT-PLUS"):
pivot[key]["hit"] += 1
return pivot
def _write_matrix_csv(rows: list[_OutcomeRow], out_path: Path) -> None:
pivot = _build_matrix(rows)
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["pattern", "subtlety", "hit_rate", "hits", "total"])
for (pattern, subtlety), counts in sorted(pivot.items()):
total = counts["total"]
rate = counts["hit"] / total if total else 0.0
w.writerow([pattern, subtlety, f"{rate:.3f}", counts["hit"], total])
def _split_rules_fired(rules_fired: str) -> set[str]:
"""Split a comma-separated ``rules_fired`` field into a set of pattern IDs."""
if not rules_fired:
return set()
return {p.strip() for p in rules_fired.split(",") if p.strip()}
def _write_summary_csv(rows: list[_OutcomeRow], out_path: Path) -> None:
"""Per-pattern aggregate, one row per active UofA core pattern.
Schema per `UofA_Phase2_M4_Cleanup_Spec.md` (closes v1.7 §13.1 gate #9
and unblocks the D1 extension spec, which appends per-COU breakdown
columns to this same file):
pattern_id, confirm_existing_count, confirm_existing_hits, recall,
negative_control_firings, gap_probe_firings,
total_firings_across_battery
All counts derived from *rows* — no re-evaluation of packages.
"""
# Per-pattern accumulators, indexed by pattern_id from _CORE_PATTERN_IDS.
confirm_count: Counter[str] = Counter() # evaluable rows only
confirm_hits: Counter[str] = Counter()
confirm_invalid: Counter[str] = Counter() # GEN-INVALID rows targeting
nc_firings: Counter[str] = Counter()
gp_firings: Counter[str] = Counter()
total_firings: Counter[str] = Counter()
# M5-B accumulators:
# targeted_firings[R] = # rows where R fired AND the row's spec targeted R
# (confirm_existing target=R, or interaction target=R)
# The denominator for precision_when_fires is total_firings[R].
targeted_firings: Counter[str] = Counter()
nc_total_evaluable = 0 # # NC rows excluding GEN-INVALID, used for nc_fpr
# D1 (v1.8): per-(pattern, base_cou_key) accumulators for per-COU recall.
per_cou_count: dict[tuple[str, str], int] = defaultdict(int)
per_cou_hits: dict[tuple[str, str], int] = defaultdict(int)
def _is_truthy(value) -> bool:
return (value is True) or (
isinstance(value, str) and value.strip().lower() == "true"
)
for r in rows:
fired = _split_rules_fired(r.rules_fired)
# Total firings across the battery — per pattern.
for pat in fired:
total_firings[pat] += 1
# confirm_existing: count attempts and hits per target pattern.
# Exclude GEN-INVALID rows from the recall denominator — those are
# generation failures, not coverage misses. But track them in
# confirm_invalid so we can distinguish "no data for this pattern"
# (empty recall) from "all data was unevaluable" (not_measurable).
if r.coverage_intent == "confirm_existing" and r.target_weakener:
if r.outcome_class == "GEN-INVALID":
confirm_invalid[r.target_weakener] += 1
if (
r.coverage_intent == "confirm_existing"
and r.target_weakener
and r.outcome_class != "GEN-INVALID"
):
confirm_count[r.target_weakener] += 1
# ``target_rule_fired`` arrives here as a Python bool from
# _OutcomeRow but the same code path runs after CSV reads
# produce strings; accept both.
hit = _is_truthy(r.target_rule_fired)
if hit:
confirm_hits[r.target_weakener] += 1
# D1: per-COU bucketing (only when row carries a base_cou_key).
cou_key = getattr(r, "base_cou_key", None)
if cou_key in _COU_KEY_TO_COLUMN:
per_cou_count[(r.target_weakener, cou_key)] += 1
if hit:
per_cou_hits[(r.target_weakener, cou_key)] += 1
if r.coverage_intent == "negative_control":
if r.outcome_class != "GEN-INVALID":
nc_total_evaluable += 1
for pat in fired:
nc_firings[pat] += 1
if r.coverage_intent == "gap_probe":
for pat in fired:
gp_firings[pat] += 1
# M5-B: a firing is "targeted" if the row's spec asked for that
# rule. confirm_existing and interaction specs declare a target
# weakener; gap_probe and negative_control never do (target=null
# for gap_probe; sentinel for NC).
if r.coverage_intent in ("confirm_existing", "interaction") and r.target_weakener:
if r.target_weakener in fired:
targeted_firings[r.target_weakener] += 1
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=SUMMARY_FIELDS)
w.writeheader()
for pat in _CORE_PATTERN_IDS:
n = confirm_count[pat]
h = confirm_hits[pat]
inv = confirm_invalid[pat]
# Recall semantics (Phase 2 §11):
# "" — no confirm_existing rows targeting this
# pattern (pattern not in the experiment)
# "<float>" — n>0 evaluable rows; standard recall
# "not_measurable" — n=0 evaluable but inv>0; e.g. weakener
# semantics force gen-invalid (W-ON-01 omits
# hasContextOfUse, W-SI-01 omits signature),
# which the SHACL gate rejects → no
# evaluable packages exist. Reporting "0%"
# would imply rules failed to fire when in
# fact rules never had a chance.
if n > 0:
recall_str = f"{(h / n):.3f}"
elif inv > 0:
recall_str = "not_measurable"
else:
recall_str = ""
# D1: per-COU recall computation.
per_cou_recall: dict[str, str] = {
col: "" for col in _COU_KEY_TO_COLUMN.values()
}
non_empty_recalls: list[float] = []
for cou_key, col_name in _COU_KEY_TO_COLUMN.items():
count = per_cou_count.get((pat, cou_key), 0)
if count == 0:
continue
hits = per_cou_hits.get((pat, cou_key), 0)
value = hits / count
per_cou_recall[col_name] = f"{value:.3f}"
non_empty_recalls.append(value)
if len(non_empty_recalls) >= 1:
recall_min = f"{min(non_empty_recalls):.3f}"
else:
recall_min = ""
if len(non_empty_recalls) >= 2:
disparity = max(non_empty_recalls) - min(non_empty_recalls)
disparity_str = f"{disparity:.3f}"
cou_dependent = (
"True" if disparity >= COU_DEPENDENT_DISPARITY_THRESHOLD
else "False"
)
else:
disparity_str = ""
cou_dependent = ""
# M5-B: per-rule precision computations.
# nc_fpr semantics: "" when no NC data exists at all (unusual);
# otherwise "0.000" through "1.000". "0.000" means rule never
# fires on clean controls; "1.000" means it fires on every
# clean control — direct §13.3 audit signal.
if nc_total_evaluable > 0:
nc_fpr_str = f"{(nc_firings[pat] / nc_total_evaluable):.3f}"
else:
nc_fpr_str = ""
# precision_when_fires semantics: "" when this rule never fired
# at all (so no precision to compute); otherwise the targeted-
# firings ratio. 1.000 = rule only fires when targeted (specific);
# 0.000 = rule never fires when targeted (always firing on
# unrelated specs, signaling broken rule).
if total_firings[pat] > 0:
precision_str = f"{(targeted_firings[pat] / total_firings[pat]):.3f}"
else:
precision_str = ""
w.writerow({
"pattern_id": pat,
"confirm_existing_count": n,
"confirm_existing_hits": h,
"recall": recall_str,
"negative_control_firings": nc_firings[pat],
"gap_probe_firings": gp_firings[pat],
"total_firings_across_battery": total_firings[pat],
"recall_morrison_cou1": per_cou_recall["recall_morrison_cou1"],
"recall_morrison_cou2": per_cou_recall["recall_morrison_cou2"],
"recall_nagaraja": per_cou_recall["recall_nagaraja"],
"recall_min_per_cou": recall_min,
"recall_cou_disparity": disparity_str,
"cou_dependent_flag": cou_dependent,
"nc_fpr": nc_fpr_str,
"precision_when_fires": precision_str,
})
RULE_TIMING_FIELDS: tuple[str, ...] = (
"rule_id",
"package_path",
"rule_eval_ms",
"rule_fired",
)
#: Fallback note recorded in <batch_dir>/batch_manifest.json (and in the
#: companion rule_timing.csv.FALLBACK_NOTE.txt) when per-rule timing is
#: unavailable from Jena natively. Per Phase 2 Spec v1.8 §10.5, under this
#: fallback rule_timing.csv is OMITTED (an all-zeros rule_eval_ms file would
#: be a misleading measurement). The lumped subprocess wall-clock instead
#: appears in outcomes.csv jena_inference_ms / total_eval_ms.
RULE_TIMING_FALLBACK_NOTE: str = (
"Per-rule wall-clock timing is not exposed by Jena's GenericRuleReasoner "
"(FORWARD_RETE) without Java-side instrumentation. Per Phase 2 Spec v1.8 "
"§10.5, rule_timing.csv is intentionally omitted under this fallback "
"rather than emitted with rule_eval_ms=0 on every row (which would be a "
"Jena-limitation marker, not a measurement). The lumped subprocess time "
"appears in outcomes.csv jena_inference_ms / total_eval_ms, and this "
"limitation is also recorded in batch_manifest.timing_fallback_note."
)
def _write_rule_timing_csv(rule_timing_rows: list[dict], out_path: Path) -> None:
"""D2 (v1.8) §10.5: per-(rule, package) timing CSV.
Reserved for the future non-fallback path (Java-side instrumentation
exposing per-rule wall-clock). Under the current Jena GenericRuleReasoner
fallback, ``run_analyze`` does NOT call this — see
``_write_rule_timing_fallback_note`` and RULE_TIMING_FALLBACK_NOTE.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=RULE_TIMING_FIELDS)
w.writeheader()
for row in rule_timing_rows:
w.writerow({k: row.get(k, "") for k in RULE_TIMING_FIELDS})
def _write_rule_timing_fallback_note(out_path: Path) -> None:
"""D2 (v1.8) §10.5 omit-with-note: write a companion text file alongside
the place rule_timing.csv would have lived, explaining the omission.
The note text is RULE_TIMING_FALLBACK_NOTE plus a header line so a human
opening the file in the batch directory understands why rule_timing.csv
is absent.
"""
out_path.parent.mkdir(parents=True, exist_ok=True)
body = (
"rule_timing.csv intentionally omitted (Jena native per-rule timing "
"fallback).\n\n"
+ RULE_TIMING_FALLBACK_NOTE
+ "\n"
)
out_path.write_text(body)
def _annotate_batch_manifest_with_timing_fallback(in_dir: Path) -> None:
"""Append D2 timing_fallback_note to the batch manifest if not already
present. Idempotent — safe to call from analyze even when the runner
didn't write it during generation."""
manifest_path = in_dir / "batch_manifest.json"
if not manifest_path.exists():
return
try:
manifest = json.loads(manifest_path.read_text())
except (OSError, json.JSONDecodeError):
return
if manifest.get("timing_fallback_note"):
return
manifest["timing_fallback_note"] = RULE_TIMING_FALLBACK_NOTE
manifest_path.write_text(json.dumps(manifest, indent=2))
def run_analyze(args) -> int:
"""Entry point for ``uofa adversarial analyze``."""
in_dir: Path = args.in_dir
out_dir: Path = args.out
pack = args.check_pack
parallel = max(1, int(getattr(args, "parallel", 1) or 1))
info(f"analyzing batch at {in_dir}" + (f" (parallel={parallel})" if parallel > 1 else ""))
try:
rows = _scan_outcomes(in_dir, pack=pack, parallel=parallel)
except FileNotFoundError as e:
error(str(e))
return 2
if not rows:
warn("no per-package rows produced; nothing to write")
return 1
out_dir.mkdir(parents=True, exist_ok=True)
outcomes_path = out_dir / "outcomes.csv"
matrix_path = out_dir / "matrix.csv"
summary_path = out_dir / "summary.csv"
rule_timing_note_path = out_dir / "rule_timing.csv.FALLBACK_NOTE.txt"
_write_outcomes_csv(rows, outcomes_path)
_write_matrix_csv(rows, matrix_path)
_write_summary_csv(rows, summary_path)
# D2 (v1.8 §10.5): rule_timing.csv is intentionally OMITTED under the
# Jena native fallback path. An all-zeros rule_eval_ms file would be a
# misleading measurement; instead, drop a companion FALLBACK_NOTE
# alongside the place where the file would have lived, and record the
# same note in batch_manifest.timing_fallback_note.
_write_rule_timing_fallback_note(rule_timing_note_path)
_annotate_batch_manifest_with_timing_fallback(in_dir)
# HTML report (delegated to reporter.py)
from uofa_cli.adversarial.reporter import write_html_report
html_path = out_dir / "index.html"
write_html_report(rows, html_path)
by_class = Counter(r.outcome_class for r in rows)
info(f" outcomes by class: {dict(by_class)}")
result_line("outcomes", True, str(outcomes_path))
result_line("matrix", True, str(matrix_path))
result_line("summary", True, str(summary_path))
result_line("rule_timing", False, "omitted — Jena native fallback "
f"(see {rule_timing_note_path.name})")
result_line("report", True, str(html_path))
# Phase 3 §2.1 bundle producer (opt-in via --emit-judge-bundle).
# Without the flag, analyze behavior is byte-for-byte unchanged.
if getattr(args, "emit_judge_bundle", False):
from uofa_cli.adversarial.judge.bundle_writer import write_bundle, BundleWriteError
bundle_path = out_dir / "judge_ready_bundle.tgz"
try:
write_result = write_bundle(in_dir, outcomes_path, bundle_path)
except BundleWriteError as e:
error(f"--emit-judge-bundle failed: {e}")
return 3
info(f" judge bundle: {write_result.package_count} packages, "
f"normalized class distribution {write_result.distribution}")
result_line("judge_bundle", True, str(bundle_path))
for w in write_result.warnings:
warn(w)
return 0