dcec9cabfb
Phase 7 filed anxious/agent-wip/release-policy-derivation as `measured` at 11.94% of audited spend on a 30-day window. The same candidate over 7 days came back `thin` at 0.00%. Only the date range differed. A human reading either issue body alone cannot know the other exists, which is rubric.md §4a's failure mode reached through the plugin's own headline number. That was caught by a human running a second skeptical review — luck, not a control. It is now mechanical, in the same register as FR-4.2's overrule-case gate: - bin/stability-classify matches each `file` candidate against the most recent prior snapshot recording it (FR-5.2 identity) and downgrades it to a boundary question if `measurement_strength` changed or the share crossed the 2% filing threshold between windows. Movement within a label is a trend, not an instability; no prior window is `unchecked`, not unstable (FR-2.4). - The downgrade is a finding against THIS repo, not the target's — the target did not change, the auditor described it two ways. Carries an `ia-stability` marker so a re-run comments rather than duplicating. - bin/ia_store.py factors the store and identity resolution out of audit-snapshot so both scripts hash signatures identically. A divergence there would make the gate match nothing and fail open. Documented as spec FR-4.5 and rubric §5b (shipped verbatim in references/). Wired into skills/offload-audit as step 4b, before filing-plan; steps 5-6 now consume its output rather than classified.json. S4 is retired in its old form — a human triage catching a bad candidate is not a repeatable test — and re-passed as: the gate catches the real release-policy-derivation case with no human in the loop. Asserted in tests/stability.test.sh case (f) against the verbatim two-window Phase 7 output. Closes kotkan/claude-plugin-inference-arbitrage#11 Suite: 178 assertions, exit 0.
253 lines
11 KiB
Python
Executable File
253 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""The evidence-stability gate (FR-4.5, rubric §5b): refuses to file a candidate
|
|
whose evidence label is an artifact of which measurement window happened to be
|
|
audited.
|
|
|
|
WHY THIS IS A GATE AND NOT A CAVEAT
|
|
-----------------------------------
|
|
Phase 7 filed `anxious/agent-wip/release-policy-derivation` on a 30-day window
|
|
as `measurement_strength: measured`, 11.94% of audited spend — a confident,
|
|
concrete cost claim. The identical candidate, classified over a 7-day window,
|
|
came back `thin`, 0.00% of spend. Nothing about the plugin changed between the
|
|
two; only the date range did.
|
|
|
|
A human reading either issue body alone has no way to know the other exists.
|
|
That is the `rubric.md` §4a failure — fast, confident, and wrong, with nobody
|
|
watching — reached by the plugin's own headline number. It was caught once, by
|
|
a human running a second skeptical review, which is not a repeatable control:
|
|
the next candidate gets filed only if someone happens to be suspicious that day.
|
|
|
|
So stability becomes mechanical, in the same register as FR-4.2's overrule-case
|
|
gate: a candidate whose evidence flips across windows is downgraded to a
|
|
boundary question and never filed, and the flip is emitted as a finding against
|
|
this plugin's own repo (FR-8.2), because a confidence label that moves with the
|
|
calendar is a bug in the auditor, not a fact about the target.
|
|
|
|
WHAT COUNTS AS A FLIP
|
|
---------------------
|
|
Two things, either of which disqualifies:
|
|
|
|
1. `measurement_strength` differs between the windows (`measured` vs `thin`
|
|
vs `unmeasured`). This is the label the issue body speaks in, so a change
|
|
here changes what a human is told.
|
|
2. `share_of_audited_spend` lands on opposite sides of MIN_SHARE_OF_SPEND.
|
|
That threshold is the filing gate itself (spec §5.2.7): if one window would
|
|
have filed on value and the other would not, the value claim is not
|
|
load-bearing evidence, it is a window artifact.
|
|
|
|
Ordinary movement within a band is NOT a flip. A candidate that measures 8% one
|
|
window and 5% the next is stable — same label, same side of the threshold — and
|
|
that variation is what `audit-snapshot diff` exists to report as a trend. This
|
|
gate fires only when the *description a human receives* changes.
|
|
|
|
NO PRIOR WINDOW MEANS UNCHECKED, NOT UNSTABLE
|
|
---------------------------------------------
|
|
A first-ever audit of a target, or a candidate seen for the first time, has
|
|
nothing to compare against. It files normally, marked
|
|
`stability: unchecked`. Manufacturing an instability finding out of an absence
|
|
of history would contradict FR-2.4 — a target with no history stays auditable on
|
|
its own terms — and would make the first audit of every plugin file nothing.
|
|
|
|
The prior window is the most recent snapshot that contains this candidate by
|
|
FR-5.2 identity (issue > signature hash > slug), not merely the most recent
|
|
snapshot: a candidate that sat out one window is still comparable against the
|
|
window before it, and using the latest snapshot unconditionally would report
|
|
every such candidate as unmatched and let it through.
|
|
|
|
Runs after `boundary-classify` and before `filing-plan`; emits the same document
|
|
shape it consumes, with verdicts revised, so it drops into the pipeline in place
|
|
of `classified.json`.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
|
|
from ia_store import (DEFAULT_STORE, MIN_SHARE_OF_SPEND, build_index,
|
|
candidate_signature, find_match, load_snapshots,
|
|
signature_hash, tiered)
|
|
|
|
GATE = "FR-4.5"
|
|
|
|
UNCHECKED_NO_HISTORY = "unchecked — no prior window to compare"
|
|
UNCHECKED_NO_MATCH = "unchecked — no prior window recorded this candidate"
|
|
|
|
|
|
def keyed(cand, scan, explicit, issue_map):
|
|
"""The identity fields FR-5.2 matches on, computed for a candidate that is
|
|
only classified and not yet snapshotted. Derived with the same
|
|
`candidate_signature` the snapshot writer uses — a divergence here would
|
|
make the gate silently match nothing and pass every unstable candidate."""
|
|
sig, source = candidate_signature(cand, scan, explicit)
|
|
return {
|
|
"candidate_id": cand.get("candidate_id"),
|
|
"issue": issue_map.get(cand.get("candidate_id")) or cand.get("issue"),
|
|
"signature_hash": signature_hash(sig),
|
|
"signature_source": source,
|
|
}
|
|
|
|
|
|
def find_prior(key, history):
|
|
"""Most recent snapshot holding this candidate, newest first."""
|
|
for snap in reversed(history):
|
|
idx = build_index([c for c, _ in tiered(snap)])
|
|
match, by = find_match(key, idx)
|
|
if match:
|
|
return snap, match, by
|
|
return None, None, None
|
|
|
|
|
|
def crosses_threshold(a, b):
|
|
return (a >= MIN_SHARE_OF_SPEND) != (b >= MIN_SHARE_OF_SPEND)
|
|
|
|
|
|
def flips(prior, curr_strength, curr_share):
|
|
"""Named, so the finding says exactly what moved rather than 'unstable'."""
|
|
out = []
|
|
prior_strength = prior.get("measurement_strength")
|
|
prior_share = (prior.get("measurement") or {}).get("share_of_plugin", 0.0) or 0.0
|
|
|
|
if prior_strength != curr_strength:
|
|
out.append({
|
|
"field": "measurement_strength",
|
|
"prior": prior_strength,
|
|
"current": curr_strength,
|
|
"why": ("the evidence label the issue body speaks in changed with the window, "
|
|
"so the same candidate would be described differently to a human "
|
|
"depending only on when the audit ran"),
|
|
})
|
|
if crosses_threshold(prior_share, curr_share):
|
|
out.append({
|
|
"field": "share_of_audited_spend",
|
|
"prior": prior_share,
|
|
"current": curr_share,
|
|
"threshold": MIN_SHARE_OF_SPEND,
|
|
"why": (f"the windows land on opposite sides of the {MIN_SHARE_OF_SPEND:.0%} "
|
|
"filing threshold — one window would file on value and the other "
|
|
"would not, so the value claim is a window artifact"),
|
|
})
|
|
return out
|
|
|
|
|
|
def gate(doc, history, scan, explicit, issue_map):
|
|
target = doc.get("target")
|
|
window = scan.get("window") or {}
|
|
findings = []
|
|
checked = 0
|
|
|
|
for cand in doc.get("candidates", []):
|
|
if cand.get("verdict") != "file":
|
|
continue
|
|
|
|
if not history:
|
|
cand["stability"] = UNCHECKED_NO_HISTORY
|
|
continue
|
|
|
|
key = keyed(cand, scan, explicit, issue_map)
|
|
snap, prior, by = find_prior(key, history)
|
|
if prior is None:
|
|
cand["stability"] = UNCHECKED_NO_MATCH
|
|
continue
|
|
|
|
checked += 1
|
|
curr_strength = cand.get("measurement_strength")
|
|
curr_share = cand.get("share_of_audited_spend", 0.0) or 0.0
|
|
moved = flips(prior, curr_strength, curr_share)
|
|
|
|
if not moved:
|
|
cand["stability"] = (
|
|
f"stable against {snap.get('run_id')} (matched by {by}): "
|
|
f"{curr_strength}, {curr_share:.2%} of audited spend either side")
|
|
continue
|
|
|
|
cand["verdict"] = "boundary-question"
|
|
cand["stability"] = f"unstable against {snap.get('run_id')} (matched by {by})"
|
|
cand.setdefault("reasons", []).append(
|
|
f"HARD GATE ({GATE}): evidence is not stable across measurement windows — "
|
|
+ "; ".join(f"{f['field']} {f['prior']!r} -> {f['current']!r}" for f in moved)
|
|
+ ". Downgraded to a boundary question, never filed."
|
|
)
|
|
|
|
findings.append({
|
|
"gate": GATE,
|
|
"candidate_id": cand.get("candidate_id"),
|
|
"target": target,
|
|
"skill": cand.get("skill"),
|
|
"matched_by": by,
|
|
"prior": {
|
|
"run_id": snap.get("run_id"),
|
|
"window": snap.get("window"),
|
|
"measurement_strength": prior.get("measurement_strength"),
|
|
"share_of_audited_spend": (prior.get("measurement") or {}).get("share_of_plugin"),
|
|
},
|
|
"current": {
|
|
"window": window,
|
|
"measurement_strength": curr_strength,
|
|
"share_of_audited_spend": curr_share,
|
|
},
|
|
"flips": moved,
|
|
"headline": (
|
|
f"{cand.get('candidate_id')} is {prior.get('measurement_strength')} at "
|
|
f"{((prior.get('measurement') or {}).get('share_of_plugin') or 0):.2%} of spend in "
|
|
f"{snap.get('run_id')} and {curr_strength} at {curr_share:.2%} in this window — "
|
|
"the confidence label depends on the measurement window, not on the candidate"),
|
|
# FR-8.2: the instability is a finding about THIS plugin, filed on
|
|
# its own repo. The marker mirrors FR-6.3's `ia-candidate` so a
|
|
# re-run comments instead of duplicating.
|
|
"self_report": {
|
|
"repo": "kotkan/claude-plugin-inference-arbitrage",
|
|
"marker": f"<!-- ia-stability: {cand.get('candidate_id')} -->",
|
|
},
|
|
})
|
|
|
|
rows = doc.get("candidates", [])
|
|
summary = doc.setdefault("summary", {})
|
|
summary["to_file"] = sum(1 for c in rows if c.get("verdict") == "file")
|
|
summary["boundary_questions"] = sum(1 for c in rows if c.get("verdict") == "boundary-question")
|
|
summary["stability_checked"] = checked
|
|
summary["stability_downgraded"] = len(findings)
|
|
|
|
doc["stability_gate"] = {
|
|
"gate": GATE,
|
|
"snapshots_available": len(history),
|
|
"checked": checked,
|
|
"downgraded": len(findings),
|
|
}
|
|
doc["stability_findings"] = findings
|
|
return doc
|
|
|
|
|
|
def load_json(path):
|
|
with open(path) as fh:
|
|
return json.load(fh)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(prog="stability-classify")
|
|
ap.add_argument("--classified", required=True, help="bin/boundary-classify output")
|
|
ap.add_argument("--target", required=True, help="target plugin name, as snapshotted")
|
|
ap.add_argument("--store", default=DEFAULT_STORE,
|
|
help=f"local mirror of the wiki store (default {DEFAULT_STORE})")
|
|
ap.add_argument("--scan", help="bin/offload-scan output, for signature identity")
|
|
ap.add_argument("--signatures", help="JSON {candidate_id: [tool signature, ...]}")
|
|
ap.add_argument("--issue-map", help='JSON {candidate_id: "owner/repo#N"}')
|
|
ap.add_argument("--exclude-run", help="run_id to treat as not-yet-recorded (tests, re-runs)")
|
|
ap.add_argument("--json", action="store_true", help="compact single-line JSON")
|
|
args = ap.parse_args()
|
|
|
|
doc = load_json(args.classified)
|
|
history = [s for s in load_snapshots(args.store, args.target)
|
|
if s.get("run_id") != args.exclude_run]
|
|
|
|
result = gate(doc, history,
|
|
load_json(args.scan) if args.scan else {},
|
|
load_json(args.signatures) if args.signatures else {},
|
|
load_json(args.issue_map) if args.issue_map else {})
|
|
|
|
json.dump(result, sys.stdout, indent=None if args.json else 2)
|
|
sys.stdout.write("\n")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|