feat(monitor): add reward-hacking measurement schemas#4474
feat(monitor): add reward-hacking measurement schemas#4474goktugozkanmd wants to merge 3 commits into
Conversation
|
This is excellent work -- the frozen containers, secret redaction on Here's the concrete Flight Recorder mapping, now that
One more thing your schema handles better than Flight Recorder currently does: when Want me to build this as an actual runnable fixture (a small script emitting real |
1 similar comment
|
This is excellent work -- the frozen containers, secret redaction on Here's the concrete Flight Recorder mapping, now that
One more thing your schema handles better than Flight Recorder currently does: when Want me to build this as an actual runnable fixture (a small script emitting real |
|
Yes, please build the small runnable fixture. No need to make it polished — one or two realistic updates from each extractor, plus one missing-data case, is enough. I mainly want to see where the schema feels awkward in real use before we wire it into the recorder. You can build against #4474 as it stands. If something doesn't fit naturally, don't work around it; leave a note and we'll fix the schema. And thanks for mapping the extractors so concretely. The EMA/rolling distinction was exactly the kind of real-world detail we needed. |
|
Built the fixture -- 3 records (2 realistic updates + the missing-data case), all validate and serialize against the current """Schema-validation fixture: Flight Recorder extractors -> inspect_ai monitor schema
(PR #4474, branch feat/monitor-schema-prototype).
Not polished -- one or two realistic updates from each of Flight Recorder's three
extractors (KL, entropy, advantage), plus one missing-data case, mapped onto
MonitorRecord/SignalValue/SignalDerivation as they currently exist on the PR branch.
Goal: surface anywhere the schema feels awkward in real use, per goktugozkanmd's ask
on #4474 -- not to build a production producer.
Run: python flight_recorder_monitor_fixture.py
"""
from __future__ import annotations
from datetime import datetime, timezone
# --- inspect_ai monitor schema, fetched from PR #4474 (feat/monitor-schema-prototype) ---
from inspect_monitor_types import (
ComponentRef,
MonitorContext,
MonitorRecord,
ProducerRef,
SignalDerivation,
SignalValue,
)
# --- Flight Recorder's actual extractors ---
import sys
sys.path.insert(0, r"C:\Users\aarav\OneDrive\Desktop\Fligh recorder")
from flightrecorder.core.kl import KLExtractor
from flightrecorder.core.entropy import EntropyExtractor
from flightrecorder.core.advantage import AdvantageExtractor
def make_context() -> MonitorContext:
return MonitorContext(
run_id="run-001",
eval_id="eval-001",
sample_uuid="sample-uuid-abc",
epoch=1,
attempt=1,
model=ComponentRef(name="qwen2.5-1.5b-instruct", version="grpo-step-120"),
)
def signal_from_kl(kl_extractor: KLExtractor, kl_scalar: float | None) -> dict[str, SignalValue]:
"""Map KLExtractor.update_scalar's 3 fields onto SignalValue/SignalDerivation.
kl_mean: instant (raw per-step scalar, no smoothing).
kl_slope/kl_accel: ema, alpha=extractor's own smoother alpha -- both derived from
the SAME Smoother instance/decay constant, since Smoother tracks first and second
finite differences of one EWMA series, not two independently-tuned EMAs.
"""
if kl_scalar is None:
# Flight Recorder currently returns NaN here (update(None, None) path) -- this
# fixture instead reports the schema's explicit "unavailable" state, which is
# exactly the improvement I flagged in my PR comment.
return {
"kl_mean": SignalValue(state="unavailable", reason="no reference logprobs this step"),
"kl_slope": SignalValue(state="unavailable", reason="no reference logprobs this step"),
"kl_accel": SignalValue(state="unavailable", reason="no reference logprobs this step"),
}
d = kl_extractor.update_scalar(kl_scalar)
return {
"kl_mean": SignalValue(value=d["kl_mean"], state="observed", derivation=SignalDerivation(method="instant")),
"kl_slope": SignalValue(value=d["kl_slope"], state="observed", derivation=SignalDerivation(method="ema", alpha=kl_extractor._s.alpha)),
"kl_accel": SignalValue(value=d["kl_accel"], state="observed", derivation=SignalDerivation(method="ema", alpha=kl_extractor._s.alpha)),
}
def signal_from_entropy(entropy_extractor: EntropyExtractor, entropy_scalar: float) -> dict[str, SignalValue]:
d = entropy_extractor.update_scalar(entropy_scalar)
return {
"entropy_mean": SignalValue(value=d["entropy_mean"], state="observed", derivation=SignalDerivation(method="instant")),
"entropy_trend": SignalValue(value=d["entropy_trend"], state="observed", derivation=SignalDerivation(method="ema", alpha=entropy_extractor._s.alpha)),
}
def signal_from_advantage(adv_extractor: AdvantageExtractor, advantages) -> dict[str, SignalValue]:
d = adv_extractor.update(advantages)
return {
"adv_var": SignalValue(value=d["adv_var"], state="observed", derivation=SignalDerivation(method="instant")),
"adv_skew": SignalValue(value=d["adv_skew"], state="observed", derivation=SignalDerivation(method="instant")),
"adv_kurtosis": SignalValue(value=d["adv_kurtosis"], state="observed", derivation=SignalDerivation(method="instant")),
# adv_drift is 0.0 (not a real signal yet) until the extractor's deque fills to
# `window` entries -- see FRICTION NOTE 2 below for how this fixture handles that.
"adv_drift": SignalValue(value=d["adv_drift"], state="observed", derivation=SignalDerivation(method="rolling", window_size=adv_extractor.window)),
}
def build_record(record_id: str, signals: dict[str, SignalValue]) -> MonitorRecord:
ctx = make_context()
return MonitorRecord(
record_id=record_id,
record_kind="attempt",
emitted_at=datetime.now(timezone.utc),
run_id=ctx.run_id,
eval_id=ctx.eval_id,
sample_uuid=ctx.sample_uuid,
dataset_sample_id=0,
epoch=ctx.epoch,
attempt=ctx.attempt,
attempt_record_ids=(),
will_retry=False,
producer=ProducerRef(name="flight-recorder", version="0.1.0", config={"estimator": "k1", "alpha": 0.3}),
model=ctx.model,
scorer=None,
execution_status="completed",
scoring_status="not_run",
signals=signals,
trace_ref=None,
)
def main() -> None:
kl_ext = KLExtractor(estimator="k1", alpha=0.3)
ent_ext = EntropyExtractor(alpha=0.3)
adv_ext = AdvantageExtractor(window=20)
print("=== Update 1: realistic training step (all extractors have data) ===")
signals_1: dict[str, SignalValue] = {}
signals_1.update(signal_from_kl(kl_ext, kl_scalar=0.012))
signals_1.update(signal_from_entropy(ent_ext, entropy_scalar=1.85))
signals_1.update(signal_from_advantage(adv_ext, advantages=[0.1, -0.2, 0.3, 0.05, -0.1] * 4))
record_1 = build_record("rec-001", signals_1)
print(record_1.model_dump_json(indent=2))
print("\n=== Update 2: second training step (KL/entropy trend now has 2 points) ===")
signals_2: dict[str, SignalValue] = {}
signals_2.update(signal_from_kl(kl_ext, kl_scalar=0.018)) # KL drifting up
signals_2.update(signal_from_entropy(ent_ext, entropy_scalar=1.60)) # entropy collapsing
signals_2.update(signal_from_advantage(adv_ext, advantages=[0.4, -0.5, 0.6, 0.15, -0.3] * 4))
record_2 = build_record("rec-002", signals_2)
print(record_2.model_dump_json(indent=2))
print("\n=== Update 3: missing-data case (no reference logprobs this step) ===")
signals_3: dict[str, SignalValue] = {}
signals_3.update(signal_from_kl(kl_ext, kl_scalar=None))
signals_3.update(signal_from_entropy(ent_ext, entropy_scalar=1.55))
signals_3.update(signal_from_advantage(adv_ext, advantages=[0.2, -0.1, 0.25, 0.1, -0.05] * 4))
record_3 = build_record("rec-003", signals_3)
print(record_3.model_dump_json(indent=2))
print("\nAll 3 records validated and serialized successfully.")
if __name__ == "__main__":
main()One real friction point, per your ask not to work around it silently:
The real issue is that Flight Recorder's extractors don't currently expose readiness (e.g. Wanted to flag this rather than just quietly hardcoding |
|
Aarav, thank you. The fixture is exactly what I needed — three concrete checks against the schema, and it surfaced nothing I need to fix in _types.py before moving forward. Two things I want to confirm from the fixture before the runtime wiring PR:
Next step: I think this schema PR is ready to move forward. The remaining work is:
I will start the runtime PR after this one merges. If you see anything in the schema that would block the connector, flag it now — otherwise I will move this PR out of draft next cycle and prepare the runtime branch. |
f394208 to
8a3e365
Compare
|
@dragonstyle, this is ready for maintainer review. I rebased it onto current |
|
Aarav, you are right to flag this. I am treating insufficient history as For production, the producer should not infer readiness from private extractor state. The extractor API should expose readiness, and the producer can translate it into the schema. I have added a cold start contract test for the rolling window, so a zero value cannot stand in for missing history. The focused test suite now passes all 38 tests, and Ruff passes on the touched file. No schema field change is needed in this PR. |
6d39609 to
1ac7eda
Compare
|
Updated this branch to current upstream \main\ in \3ace5e2dd. The Monitoring changelog entry was restored to ## Unreleased\ after the merge. Fresh local verification: \uv run pytest tests/monitor/test_types.py -q\ (38 passed), Ruff check/format pass, and mypy passes for the monitor source and tests. GitHub now reports the PR as mergeable; the remaining block is maintainer review. No remote checks are currently reported for the fork branch. |
Summary
This draft adds a narrow, serializable monitor schema prototype for the reward-hacking measurement work discussed in #4456.
MonitorContextinstant, EMA, rolling, and named custom signal derivationsMonitorRecordenvelopes with retry and scoring statusThis intentionally does not wire runtime hooks or a recorder yet. The goal is to agree on the schema boundary first.
@Aarav500 this turns the examples from #4456 into concrete public types. In particular, the
windowquestion is represented bySignalDerivation:method="ema", alpha=0.3,method="rolling", window_size=20, ormethod="instant".Testing
uv run pytest -q tests/monitor/test_types.pyuv run ruff check src/inspect_ai/monitor tests/monitoruv run mypy --exclude tests/test_package src/inspect_ai/monitor tests/monitoruv run pytest -q -n autoChecklist