mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-23 01:55:09 +02:00
* fix(skill-comply): stop a failed step supplying evidence downstream `_check_temporal_order` fell back to the raw classifier output whenever the referenced step was absent from `resolved`. A step only enters `resolved` once it passes, so "failed" and "not graded yet" were the same thing to that lookup, and a dependant could pass on an event belonging to a prerequisite that had failed its own ordering check. With three steps C, A (before C) and B (after A) and events C@T0, A@T1, B@T2, A fails and B passed on A's classified event: 2/3 instead of 1/3. It compounds down a chain, so one failed prerequisite could leave a five-step workflow reading 4/5. The grader now tracks which steps have been graded at all. A referenced step that was graded and is missing from `resolved` failed, and its events are refused with a reason that says so. A step not graded yet is a forward reference to a step declared later, and the fallback stays as it was: that is what makes an out-of-order declaration work, and the existing regression for it goes red if the fallback is removed instead. `before_step` deliberately keeps the old fallback. The two fail in opposite directions: an `after_step` fallback can only turn a failure into a pass, a `before_step` one can only turn a pass into a failure, so dropping it would relax a constraint because some other step failed. * fix(skill-comply): revoke a pass that rested on a later-failing prerequisite Review of #3109 found the mirror image of the case that PR fixes. `graded` only catches a prerequisite that had already failed when its dependant was graded. A step declared *before* its `after_step` is graded against the classifier's raw events for a step that has not run yet — the fallback that makes an out-of-order declaration work — and nothing revisited it once that step went on to fail its own checks. Add a pass after grading that demotes any detected step whose `after_step` ended up failing, repeated to a fixed point: one demotion can invalidate whatever depended on it, in either declaration order. Demotion only removes passes, so it terminates. `compliance_rate` is computed from the demoted results. Also from review: build `graded` and the chain test's step list as new objects rather than mutating (AGENTS.md immutability rule), and annotate the injected mocks in the tests this PR owns.
195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""Grade observation traces against compliance specs using LLM classification."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, replace
|
|
|
|
from scripts.classifier import classify_events
|
|
from scripts.parser import ComplianceSpec, ObservationEvent, Step
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StepResult:
|
|
step_id: str
|
|
detected: bool
|
|
evidence: tuple[ObservationEvent, ...]
|
|
failure_reason: str | None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ComplianceResult:
|
|
spec_id: str
|
|
steps: tuple[StepResult, ...]
|
|
compliance_rate: float
|
|
recommend_hook_promotion: bool
|
|
classification: dict[str, list[int]]
|
|
|
|
|
|
def _check_temporal_order(
|
|
step: Step,
|
|
event: ObservationEvent,
|
|
resolved: dict[str, list[ObservationEvent]],
|
|
classified: dict[str, list[ObservationEvent]],
|
|
graded: set[str],
|
|
) -> str | None:
|
|
"""Check before_step/after_step constraints. Returns failure reason or None."""
|
|
if step.detector.after_step is not None:
|
|
after_step = step.detector.after_step
|
|
after_events = resolved.get(after_step)
|
|
if after_events is None:
|
|
if after_step in graded:
|
|
# Graded and missing from `resolved` means it failed its own checks.
|
|
# Its classified events are not evidence for anything: reusing them
|
|
# here let a dependant pass on the strength of a failed prerequisite,
|
|
# and the overstatement carried down the whole chain.
|
|
return f"after_step '{after_step}' did not pass its own checks"
|
|
# Not graded yet, so this is a forward reference to a step declared later.
|
|
# The classifier's own output is the only thing available, and using it is
|
|
# what makes an out-of-order declaration work.
|
|
after_events = classified.get(after_step, [])
|
|
if not after_events:
|
|
return f"after_step '{after_step}' not yet detected"
|
|
latest_after = max(e.timestamp for e in after_events)
|
|
if event.timestamp <= latest_after:
|
|
return (
|
|
f"must occur after '{after_step}' "
|
|
f"(last at {latest_after}), but found at {event.timestamp}"
|
|
)
|
|
|
|
if step.detector.before_step is not None:
|
|
# Look ahead using LLM classification results. A failed reference step is NOT
|
|
# excluded here the way it is above: the two fall in opposite directions. An
|
|
# `after_step` fallback can only turn a failure into a pass, while a
|
|
# `before_step` one can only turn a pass into a failure, so dropping it would
|
|
# relax a constraint because some other step failed.
|
|
before_events = resolved.get(step.detector.before_step)
|
|
if before_events is None:
|
|
before_events = classified.get(step.detector.before_step, [])
|
|
if before_events:
|
|
earliest_before = min(e.timestamp for e in before_events)
|
|
if event.timestamp >= earliest_before:
|
|
return (
|
|
f"must occur before '{step.detector.before_step}' "
|
|
f"(first at {earliest_before}), but found at {event.timestamp}"
|
|
)
|
|
|
|
return None
|
|
|
|
|
|
def _demote_steps_resting_on_failures(
|
|
step_results: tuple[StepResult, ...],
|
|
after_steps: dict[str, str],
|
|
) -> tuple[StepResult, ...]:
|
|
"""Undo passes that rest on an `after_step` which ended up failing.
|
|
|
|
A step declared before its prerequisite is graded against the classifier's raw
|
|
events for that step, because `resolved` has nothing for it yet. That fallback is
|
|
what makes an out-of-order declaration work, but the prerequisite can go on to
|
|
fail its own checks, and then the dependant is left passing on evidence that
|
|
never held. Repeat until nothing changes: demoting one step can invalidate
|
|
whatever depended on it, whichever order the two were declared in. Demotion only
|
|
ever removes passes, so the loop terminates.
|
|
"""
|
|
results = step_results
|
|
while True:
|
|
failed = {result.step_id for result in results if not result.detected}
|
|
demote = {
|
|
result.step_id
|
|
for result in results
|
|
if result.detected and after_steps.get(result.step_id) in failed
|
|
}
|
|
if not demote:
|
|
return results
|
|
results = tuple(
|
|
replace(
|
|
result,
|
|
detected=False,
|
|
evidence=(),
|
|
failure_reason=(
|
|
f"after_step '{after_steps[result.step_id]}' "
|
|
"did not pass its own checks"
|
|
),
|
|
)
|
|
if result.step_id in demote
|
|
else result
|
|
for result in results
|
|
)
|
|
|
|
|
|
def grade(
|
|
spec: ComplianceSpec,
|
|
trace: list[ObservationEvent],
|
|
classifier_model: str = "haiku",
|
|
) -> ComplianceResult:
|
|
"""Grade a trace against a compliance spec using LLM classification."""
|
|
sorted_trace = sorted(trace, key=lambda e: e.timestamp)
|
|
|
|
# Step 1: LLM classifies all events in one batch call
|
|
classification = classify_events(spec, sorted_trace, model=classifier_model)
|
|
|
|
# Convert indices to events
|
|
classified: dict[str, list[ObservationEvent]] = {
|
|
step_id: [sorted_trace[i] for i in indices if 0 <= i < len(sorted_trace)]
|
|
for step_id, indices in classification.items()
|
|
}
|
|
|
|
# Step 2: Check temporal ordering (deterministic)
|
|
resolved: dict[str, list[ObservationEvent]] = {}
|
|
# Steps already graded, pass or fail. `resolved` alone cannot tell "failed" from
|
|
# "not reached yet", and those need opposite answers in `_check_temporal_order`.
|
|
graded: set[str] = set()
|
|
step_results: list[StepResult] = []
|
|
|
|
for step in spec.steps:
|
|
candidates = classified.get(step.id, [])
|
|
matched: list[ObservationEvent] = []
|
|
failure_reason: str | None = None
|
|
|
|
for event in candidates:
|
|
temporal_fail = _check_temporal_order(step, event, resolved, classified, graded)
|
|
if temporal_fail is None:
|
|
matched.append(event)
|
|
break
|
|
else:
|
|
failure_reason = temporal_fail
|
|
|
|
detected = len(matched) > 0
|
|
if detected:
|
|
resolved[step.id] = matched
|
|
elif failure_reason is None:
|
|
failure_reason = f"no matching event classified for step '{step.id}'"
|
|
|
|
graded = graded | {step.id}
|
|
step_results.append(StepResult(
|
|
step_id=step.id,
|
|
detected=detected,
|
|
evidence=tuple(matched),
|
|
failure_reason=failure_reason if not detected else None,
|
|
))
|
|
|
|
# `graded` catches a prerequisite that had already failed when its dependant was
|
|
# graded. The other direction needs a second pass: a dependant declared first is
|
|
# graded against the classifier's raw events for a prerequisite that has not run
|
|
# yet, and only later does that prerequisite fail.
|
|
after_steps = {
|
|
step.id: step.detector.after_step
|
|
for step in spec.steps
|
|
if step.detector.after_step is not None
|
|
}
|
|
resolved_results = _demote_steps_resting_on_failures(tuple(step_results), after_steps)
|
|
|
|
required_ids = {s.id for s in spec.steps if s.required}
|
|
required_steps = [s for s in resolved_results if s.step_id in required_ids]
|
|
detected_required = sum(1 for s in required_steps if s.detected)
|
|
total_required = len(required_steps)
|
|
|
|
compliance_rate = detected_required / total_required if total_required > 0 else 0.0
|
|
|
|
return ComplianceResult(
|
|
spec_id=spec.id,
|
|
steps=resolved_results,
|
|
compliance_rate=compliance_rate,
|
|
recommend_hook_promotion=compliance_rate < spec.threshold_promote_to_hook,
|
|
classification=classification,
|
|
)
|