Add falsification scenario layer
This commit is contained in:
parent
f9205cc86e
commit
d9276eec31
7 changed files with 658 additions and 5 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
{
|
{
|
||||||
"report_sha256": "c97b2dca7282d0231f1b448add87256cc2f59d39c5cec5ac1350231541e07d0b"
|
"frame_report_sha256": "c97b2dca7282d0231f1b448add87256cc2f59d39c5cec5ac1350231541e07d0b",
|
||||||
|
"report_sha256": "e0903e408f882bf7c93b7aadb6b3f8a064438ac09dc1cd4139f381bdc520b923"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,12 @@ from sensorium.audio.compiler import AudioCompiler
|
||||||
from sensorium.audio.checksum import sha256_json
|
from sensorium.audio.checksum import sha256_json
|
||||||
from sensorium.environment import (
|
from sensorium.environment import (
|
||||||
ObservationUnitRef,
|
ObservationUnitRef,
|
||||||
|
build_experiment_plan,
|
||||||
build_expected_observation_frame,
|
build_expected_observation_frame,
|
||||||
|
build_hypothesis_claim,
|
||||||
build_observation_frame,
|
build_observation_frame,
|
||||||
compare_expected_to_observation,
|
compare_expected_to_observation,
|
||||||
|
run_falsification_scenario,
|
||||||
)
|
)
|
||||||
from sensorium.sensorimotor.compiler import SensorimotorCompiler
|
from sensorium.sensorimotor.compiler import SensorimotorCompiler
|
||||||
from sensorium.vision import VisionCompiler, canonicalize_image
|
from sensorium.vision import VisionCompiler, canonicalize_image
|
||||||
|
|
@ -107,15 +110,13 @@ def _report_hash(report_without_hash: dict[str, object]) -> str:
|
||||||
return sha256_json(report_without_hash)
|
return sha256_json(report_without_hash)
|
||||||
|
|
||||||
|
|
||||||
def build_environment_falsification_report() -> dict[str, object]:
|
def _frame_report(cases: list[dict[str, object]]) -> dict[str, object]:
|
||||||
fixtures = _load_json("fixtures.json")["fixtures"]
|
|
||||||
cases = [_case_report(idx, case) for idx, case in enumerate(fixtures)]
|
|
||||||
passed = sum(
|
passed = sum(
|
||||||
1
|
1
|
||||||
for case in cases
|
for case in cases
|
||||||
if case["verdict_ok"] is True and case["trace_hygiene_ok"] is True
|
if case["verdict_ok"] is True and case["trace_hygiene_ok"] is True
|
||||||
)
|
)
|
||||||
report = {
|
return {
|
||||||
"lane": "environment-falsification",
|
"lane": "environment-falsification",
|
||||||
"version": "v1",
|
"version": "v1",
|
||||||
"total": len(cases),
|
"total": len(cases),
|
||||||
|
|
@ -123,10 +124,108 @@ def build_environment_falsification_report() -> dict[str, object]:
|
||||||
"failed": len(cases) - passed,
|
"failed": len(cases) - passed,
|
||||||
"cases": cases,
|
"cases": cases,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_case_report(index: int, scenario: dict[str, Any]) -> dict[str, object]:
|
||||||
|
hypothesis_spec = scenario["hypothesis"]
|
||||||
|
hypothesis = build_hypothesis_claim(
|
||||||
|
claim_id=str(hypothesis_spec["claim_id"]),
|
||||||
|
claim_text=str(hypothesis_spec["claim_text"]),
|
||||||
|
domain=str(hypothesis_spec["domain"]),
|
||||||
|
basis_trace_hashes=tuple(hypothesis_spec.get("basis_trace_hashes", ())),
|
||||||
|
)
|
||||||
|
expected_frames = []
|
||||||
|
actual_frames_by_expected_id = {}
|
||||||
|
actual_refs_by_expected_id = {}
|
||||||
|
for offset, frame_spec in enumerate(scenario["frames"]):
|
||||||
|
tick = int(frame_spec.get("tick", index * 100 + offset))
|
||||||
|
expected_refs = _refs(frame_spec["expected"])
|
||||||
|
expected = build_expected_observation_frame(
|
||||||
|
monotonic_tick=tick,
|
||||||
|
source_clock="environment-falsification-scenario-fixture",
|
||||||
|
unit_refs=expected_refs,
|
||||||
|
causal_parent_ids=tuple(frame_spec.get("causal_parent_ids", ())),
|
||||||
|
)
|
||||||
|
expected_frames.append(expected)
|
||||||
|
if frame_spec["actual"] == "missing":
|
||||||
|
continue
|
||||||
|
actual_spec = frame_spec["expected"] if frame_spec["actual"] == "same" else frame_spec["actual"]
|
||||||
|
actual_refs = _refs(actual_spec)
|
||||||
|
actual = build_observation_frame(
|
||||||
|
monotonic_tick=tick,
|
||||||
|
source_clock="environment-falsification-scenario-fixture",
|
||||||
|
units=tuple(ref.unit for ref in actual_refs),
|
||||||
|
causal_parent_ids=(expected.expected_id,),
|
||||||
|
)
|
||||||
|
actual_frames_by_expected_id[expected.expected_id] = actual
|
||||||
|
actual_refs_by_expected_id[expected.expected_id] = actual_refs
|
||||||
|
|
||||||
|
plan = build_experiment_plan(hypothesis=hypothesis, expected_frames=expected_frames)
|
||||||
|
report = run_falsification_scenario(
|
||||||
|
plan,
|
||||||
|
actual_frames_by_expected_id=actual_frames_by_expected_id,
|
||||||
|
actual_refs_by_expected_id=actual_refs_by_expected_id,
|
||||||
|
)
|
||||||
|
expected_verdict = str(scenario["expected_verdict"])
|
||||||
|
row = {
|
||||||
|
"id": scenario["id"],
|
||||||
|
"expected_verdict": expected_verdict,
|
||||||
|
"actual_verdict": report.verdict,
|
||||||
|
"verdict_ok": report.verdict == expected_verdict,
|
||||||
|
"trace_hygiene_ok": _trace_safe(report.as_dict()),
|
||||||
|
"hypothesis_sha256": hypothesis.hypothesis_sha256,
|
||||||
|
"plan_sha256": plan.plan_sha256,
|
||||||
|
"scenario_sha256": report.scenario_sha256,
|
||||||
|
"scenario_report_sha256": report.report_sha256,
|
||||||
|
"total_count": report.total_count,
|
||||||
|
"supported_count": report.supported_count,
|
||||||
|
"falsified_count": report.falsified_count,
|
||||||
|
"run_trace_hashes": [run.trace_hash for run in report.runs],
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def build_environment_falsification_report() -> dict[str, object]:
|
||||||
|
fixtures = _load_json("fixtures.json")["fixtures"]
|
||||||
|
cases = [_case_report(idx, case) for idx, case in enumerate(fixtures)]
|
||||||
|
frame_report = _frame_report(cases)
|
||||||
|
frame_report_sha256 = _report_hash(frame_report)
|
||||||
|
scenario_fixtures = _load_json("scenario_fixtures.json")["scenarios"]
|
||||||
|
scenario_cases = [
|
||||||
|
_scenario_case_report(idx, scenario)
|
||||||
|
for idx, scenario in enumerate(scenario_fixtures)
|
||||||
|
]
|
||||||
|
scenario_passed = sum(
|
||||||
|
1
|
||||||
|
for case in scenario_cases
|
||||||
|
if case["verdict_ok"] is True and case["trace_hygiene_ok"] is True
|
||||||
|
)
|
||||||
|
frame_passed = int(frame_report["passed"])
|
||||||
|
frame_failed = int(frame_report["failed"])
|
||||||
|
total = len(cases) + len(scenario_cases)
|
||||||
|
passed = frame_passed + scenario_passed
|
||||||
|
report = {
|
||||||
|
"lane": "environment-falsification",
|
||||||
|
"version": "v1",
|
||||||
|
"total": total,
|
||||||
|
"passed": passed,
|
||||||
|
"failed": frame_failed + (len(scenario_cases) - scenario_passed),
|
||||||
|
"cases": cases,
|
||||||
|
"frame_report_sha256": frame_report_sha256,
|
||||||
|
"scenario_cases": scenario_cases,
|
||||||
|
}
|
||||||
report["report_sha256"] = _report_hash(report)
|
report["report_sha256"] = _report_hash(report)
|
||||||
expected_hashes = _load_json("expected_hashes.json")
|
expected_hashes = _load_json("expected_hashes.json")
|
||||||
|
expected_frame_report_sha256 = expected_hashes.get(
|
||||||
|
"frame_report_sha256",
|
||||||
|
expected_hashes["report_sha256"],
|
||||||
|
)
|
||||||
|
report["expected_frame_report_sha256"] = expected_frame_report_sha256
|
||||||
|
report["expected_frame_report_hash_ok"] = frame_report_sha256 == expected_frame_report_sha256
|
||||||
report["expected_report_sha256"] = expected_hashes["report_sha256"]
|
report["expected_report_sha256"] = expected_hashes["report_sha256"]
|
||||||
report["expected_report_hash_ok"] = report["report_sha256"] == expected_hashes["report_sha256"]
|
report["expected_report_hash_ok"] = report["report_sha256"] == expected_hashes["report_sha256"]
|
||||||
|
if not report["expected_frame_report_hash_ok"]:
|
||||||
|
report["failed"] = int(report["failed"]) + 1
|
||||||
if not report["expected_report_hash_ok"]:
|
if not report["expected_report_hash_ok"]:
|
||||||
report["failed"] = int(report["failed"]) + 1
|
report["failed"] = int(report["failed"]) + 1
|
||||||
return report
|
return report
|
||||||
|
|
|
||||||
88
evals/environment_falsification/scenario_fixtures.json
Normal file
88
evals/environment_falsification/scenario_fixtures.json
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
{
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "supported_passive_contact_hypothesis",
|
||||||
|
"expected_verdict": "SUPPORTED",
|
||||||
|
"hypothesis": {
|
||||||
|
"claim_id": "passive-contact-stays-anchored",
|
||||||
|
"claim_text": "When the passive contact fixture remains anchored, audio, vision, and contact evidence repeat exactly.",
|
||||||
|
"domain": "passive_tabletop",
|
||||||
|
"basis_trace_hashes": ["basis-passive-contact", "basis-vision-anchor", "basis-passive-contact"]
|
||||||
|
},
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"tick": 10,
|
||||||
|
"expected": {
|
||||||
|
"audio:left_tone": {
|
||||||
|
"modality": "audio",
|
||||||
|
"signal": {"id": "scenario_left_tone", "kind": "tone", "ms": 240, "hz": 180, "sweep": 0, "amp": 0.4}
|
||||||
|
},
|
||||||
|
"vision:corner": {
|
||||||
|
"modality": "vision",
|
||||||
|
"signal": {"id": "scenario_corner", "kind": "corner", "size": 32}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actual": "same"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tick": 11,
|
||||||
|
"expected": {
|
||||||
|
"sensorimotor:contact": {
|
||||||
|
"modality": "sensorimotor",
|
||||||
|
"signal": {
|
||||||
|
"id": "scenario_contact",
|
||||||
|
"pose_q": [10, -4, 3],
|
||||||
|
"velocity_q": [1, 0, -1],
|
||||||
|
"force_torque_q": [2, 3, 5],
|
||||||
|
"contact_q": [1, 0, 1, 0],
|
||||||
|
"actuator_state_q": [7, 8]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actual": "same"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "falsified_passive_contact_hypothesis",
|
||||||
|
"expected_verdict": "FALSIFIED",
|
||||||
|
"hypothesis": {
|
||||||
|
"claim_id": "passive-contact-breaks-on-release",
|
||||||
|
"claim_text": "A released passive contact fixture should continue to emit the original contact evidence.",
|
||||||
|
"domain": "passive_tabletop",
|
||||||
|
"basis_trace_hashes": ["basis-contact-release"]
|
||||||
|
},
|
||||||
|
"frames": [
|
||||||
|
{
|
||||||
|
"tick": 20,
|
||||||
|
"expected": {
|
||||||
|
"sensorimotor:contact": {
|
||||||
|
"modality": "sensorimotor",
|
||||||
|
"signal": {
|
||||||
|
"id": "scenario_contact_expected",
|
||||||
|
"pose_q": [10, -4, 3],
|
||||||
|
"velocity_q": [1, 0, -1],
|
||||||
|
"force_torque_q": [2, 3, 5],
|
||||||
|
"contact_q": [1, 0, 1, 0],
|
||||||
|
"actuator_state_q": [7, 8]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"actual": {
|
||||||
|
"sensorimotor:contact": {
|
||||||
|
"modality": "sensorimotor",
|
||||||
|
"signal": {
|
||||||
|
"id": "scenario_contact_actual",
|
||||||
|
"pose_q": [10, -4, 3],
|
||||||
|
"velocity_q": [1, 0, -1],
|
||||||
|
"force_torque_q": [2, 3, 5],
|
||||||
|
"contact_q": [0, 0, 0, 0],
|
||||||
|
"actuator_state_q": [7, 8]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -11,16 +11,36 @@ from sensorium.environment.falsification import (
|
||||||
)
|
)
|
||||||
from sensorium.environment.frame import ObservationFrame, build_observation_frame
|
from sensorium.environment.frame import ObservationFrame, build_observation_frame
|
||||||
from sensorium.environment.harness import build_fixture_observation_frame
|
from sensorium.environment.harness import build_fixture_observation_frame
|
||||||
|
from sensorium.environment.scenario import (
|
||||||
|
ExperimentPlan,
|
||||||
|
FalsificationScenario,
|
||||||
|
HypothesisClaim,
|
||||||
|
ScenarioActualFrame,
|
||||||
|
ScenarioReport,
|
||||||
|
build_experiment_plan,
|
||||||
|
build_falsification_scenario,
|
||||||
|
build_hypothesis_claim,
|
||||||
|
run_falsification_scenario,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ChangedSlot",
|
"ChangedSlot",
|
||||||
|
"ExperimentPlan",
|
||||||
"ExpectedObservationFrame",
|
"ExpectedObservationFrame",
|
||||||
|
"FalsificationScenario",
|
||||||
"FalsificationResidual",
|
"FalsificationResidual",
|
||||||
"FalsificationRun",
|
"FalsificationRun",
|
||||||
|
"HypothesisClaim",
|
||||||
"ObservationFrame",
|
"ObservationFrame",
|
||||||
"ObservationUnitRef",
|
"ObservationUnitRef",
|
||||||
|
"ScenarioActualFrame",
|
||||||
|
"ScenarioReport",
|
||||||
|
"build_experiment_plan",
|
||||||
"build_expected_observation_frame",
|
"build_expected_observation_frame",
|
||||||
|
"build_falsification_scenario",
|
||||||
"build_fixture_observation_frame",
|
"build_fixture_observation_frame",
|
||||||
|
"build_hypothesis_claim",
|
||||||
"build_observation_frame",
|
"build_observation_frame",
|
||||||
"compare_expected_to_observation",
|
"compare_expected_to_observation",
|
||||||
|
"run_falsification_scenario",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
338
sensorium/environment/scenario.py
Normal file
338
sensorium/environment/scenario.py
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
"""Hypothesis-scoped environmental falsification scenarios."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Iterable, Mapping
|
||||||
|
|
||||||
|
from sensorium.audio.checksum import sha256_json
|
||||||
|
from sensorium.environment.falsification import (
|
||||||
|
ExpectedObservationFrame,
|
||||||
|
FalsificationResidual,
|
||||||
|
FalsificationRun,
|
||||||
|
ObservationUnitRef,
|
||||||
|
compare_expected_to_observation,
|
||||||
|
)
|
||||||
|
from sensorium.environment.frame import ObservationFrame
|
||||||
|
|
||||||
|
SPECULATIVE_STATUS = "SPECULATIVE"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class HypothesisClaim:
|
||||||
|
"""Speculative claim that gives expected evidence a reason to exist."""
|
||||||
|
|
||||||
|
claim_id: str
|
||||||
|
claim_text: str
|
||||||
|
domain: str
|
||||||
|
basis_trace_hashes: tuple[str, ...]
|
||||||
|
hypothesis_sha256: str
|
||||||
|
epistemic_status: str = SPECULATIVE_STATUS
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"claim_id": self.claim_id,
|
||||||
|
"claim_text": self.claim_text,
|
||||||
|
"domain": self.domain,
|
||||||
|
"basis_trace_hashes": list(self.basis_trace_hashes),
|
||||||
|
"hypothesis_sha256": self.hypothesis_sha256,
|
||||||
|
"epistemic_status": self.epistemic_status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_hypothesis_claim(
|
||||||
|
*,
|
||||||
|
claim_id: str,
|
||||||
|
claim_text: str,
|
||||||
|
domain: str,
|
||||||
|
basis_trace_hashes: Iterable[str] = (),
|
||||||
|
) -> HypothesisClaim:
|
||||||
|
if not claim_id.strip():
|
||||||
|
raise ValueError("HypothesisClaim.claim_id is required")
|
||||||
|
if not claim_text.strip():
|
||||||
|
raise ValueError("HypothesisClaim.claim_text is required")
|
||||||
|
if not domain.strip():
|
||||||
|
raise ValueError("HypothesisClaim.domain is required")
|
||||||
|
basis = tuple(sorted(set(str(h) for h in basis_trace_hashes if str(h).strip())))
|
||||||
|
payload = {
|
||||||
|
"kind": "HypothesisClaim",
|
||||||
|
"claim_id": claim_id,
|
||||||
|
"claim_text": claim_text,
|
||||||
|
"domain": domain,
|
||||||
|
"basis_trace_hashes": list(basis),
|
||||||
|
"epistemic_status": SPECULATIVE_STATUS,
|
||||||
|
}
|
||||||
|
return HypothesisClaim(
|
||||||
|
claim_id=claim_id,
|
||||||
|
claim_text=claim_text,
|
||||||
|
domain=domain,
|
||||||
|
basis_trace_hashes=basis,
|
||||||
|
hypothesis_sha256=sha256_json(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_expected_frames(
|
||||||
|
frames: Iterable[ExpectedObservationFrame],
|
||||||
|
) -> tuple[ExpectedObservationFrame, ...]:
|
||||||
|
ordered = sorted(tuple(frames), key=lambda f: (f.expected_id, f.expected_sha256))
|
||||||
|
deduped: list[ExpectedObservationFrame] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
seen_ids: dict[str, str] = {}
|
||||||
|
for frame in ordered:
|
||||||
|
key = (frame.expected_id, frame.expected_sha256)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
if frame.expected_id in seen_ids and seen_ids[frame.expected_id] != frame.expected_sha256:
|
||||||
|
raise ValueError(f"conflicting expected frame id: {frame.expected_id}")
|
||||||
|
seen.add(key)
|
||||||
|
seen_ids[frame.expected_id] = frame.expected_sha256
|
||||||
|
deduped.append(frame)
|
||||||
|
return tuple(deduped)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExperimentPlan:
|
||||||
|
"""One speculative hypothesis plus canonical expected evidence frames."""
|
||||||
|
|
||||||
|
hypothesis: HypothesisClaim
|
||||||
|
expected_frames: tuple[ExpectedObservationFrame, ...]
|
||||||
|
plan_sha256: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"hypothesis": self.hypothesis.as_dict(),
|
||||||
|
"expected_frames": [frame.as_dict() for frame in self.expected_frames],
|
||||||
|
"plan_sha256": self.plan_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_experiment_plan(
|
||||||
|
*,
|
||||||
|
hypothesis: HypothesisClaim,
|
||||||
|
expected_frames: Iterable[ExpectedObservationFrame],
|
||||||
|
) -> ExperimentPlan:
|
||||||
|
frames = _canonical_expected_frames(expected_frames)
|
||||||
|
if not frames:
|
||||||
|
raise ValueError("ExperimentPlan requires at least one expected frame")
|
||||||
|
payload = {
|
||||||
|
"kind": "ExperimentPlan",
|
||||||
|
"hypothesis": hypothesis.as_dict(),
|
||||||
|
"expected_frames": [frame.as_dict() for frame in frames],
|
||||||
|
}
|
||||||
|
return ExperimentPlan(
|
||||||
|
hypothesis=hypothesis,
|
||||||
|
expected_frames=frames,
|
||||||
|
plan_sha256=sha256_json(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScenarioActualFrame:
|
||||||
|
"""Actual observation frame bound to one expected frame id."""
|
||||||
|
|
||||||
|
expected_id: str
|
||||||
|
frame: ObservationFrame
|
||||||
|
actual_refs: tuple[ObservationUnitRef, ...]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"expected_id": self.expected_id,
|
||||||
|
"actual_frame_id": self.frame.frame_id,
|
||||||
|
"actual_trace_hash": self.frame.trace_hash,
|
||||||
|
"actual_ref_slots": [ref.slot_id for ref in self.actual_refs],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FalsificationScenario:
|
||||||
|
"""Immutable scenario binding one plan to actual observation frames."""
|
||||||
|
|
||||||
|
plan: ExperimentPlan
|
||||||
|
actual_frames: tuple[ScenarioActualFrame, ...]
|
||||||
|
scenario_sha256: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"plan": self.plan.as_dict(),
|
||||||
|
"actual_frames": [frame.as_dict() for frame in self.actual_frames],
|
||||||
|
"scenario_sha256": self.scenario_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ScenarioReport:
|
||||||
|
"""Aggregate report over ordered falsification runs."""
|
||||||
|
|
||||||
|
hypothesis_sha256: str
|
||||||
|
plan_sha256: str
|
||||||
|
scenario_sha256: str
|
||||||
|
verdict: str
|
||||||
|
total_count: int
|
||||||
|
supported_count: int
|
||||||
|
falsified_count: int
|
||||||
|
runs: tuple[FalsificationRun, ...]
|
||||||
|
report_sha256: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"hypothesis_sha256": self.hypothesis_sha256,
|
||||||
|
"plan_sha256": self.plan_sha256,
|
||||||
|
"scenario_sha256": self.scenario_sha256,
|
||||||
|
"verdict": self.verdict,
|
||||||
|
"total_count": self.total_count,
|
||||||
|
"supported_count": self.supported_count,
|
||||||
|
"falsified_count": self.falsified_count,
|
||||||
|
"runs": [run.as_dict() for run in self.runs],
|
||||||
|
"report_sha256": self.report_sha256,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _actual_frame_items(
|
||||||
|
plan: ExperimentPlan,
|
||||||
|
actual_frames_by_expected_id: Mapping[str, ObservationFrame],
|
||||||
|
actual_refs_by_expected_id: Mapping[str, Iterable[ObservationUnitRef]] | None,
|
||||||
|
) -> tuple[ScenarioActualFrame, ...]:
|
||||||
|
refs_by_id = actual_refs_by_expected_id or {}
|
||||||
|
expected_ids = {expected.expected_id for expected in plan.expected_frames}
|
||||||
|
unknown_actual = sorted(set(actual_frames_by_expected_id) - expected_ids)
|
||||||
|
if unknown_actual:
|
||||||
|
raise ValueError(f"actual frame supplied for unknown expected_id: {unknown_actual[0]}")
|
||||||
|
unknown_refs = sorted(set(refs_by_id) - expected_ids)
|
||||||
|
if unknown_refs:
|
||||||
|
raise ValueError(f"actual refs supplied for unknown expected_id: {unknown_refs[0]}")
|
||||||
|
items: list[ScenarioActualFrame] = []
|
||||||
|
for expected in plan.expected_frames:
|
||||||
|
actual = actual_frames_by_expected_id.get(expected.expected_id)
|
||||||
|
if actual is None:
|
||||||
|
continue
|
||||||
|
refs = tuple(refs_by_id.get(expected.expected_id, ()))
|
||||||
|
items.append(ScenarioActualFrame(expected.expected_id, actual, refs))
|
||||||
|
return tuple(items)
|
||||||
|
|
||||||
|
|
||||||
|
def build_falsification_scenario(
|
||||||
|
*,
|
||||||
|
plan: ExperimentPlan,
|
||||||
|
actual_frames_by_expected_id: Mapping[str, ObservationFrame],
|
||||||
|
actual_refs_by_expected_id: Mapping[str, Iterable[ObservationUnitRef]] | None = None,
|
||||||
|
) -> FalsificationScenario:
|
||||||
|
actual_frames = _actual_frame_items(
|
||||||
|
plan,
|
||||||
|
actual_frames_by_expected_id,
|
||||||
|
actual_refs_by_expected_id,
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
"kind": "FalsificationScenario",
|
||||||
|
"plan_sha256": plan.plan_sha256,
|
||||||
|
"actual_frames": [frame.as_dict() for frame in actual_frames],
|
||||||
|
}
|
||||||
|
return FalsificationScenario(
|
||||||
|
plan=plan,
|
||||||
|
actual_frames=actual_frames,
|
||||||
|
scenario_sha256=sha256_json(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_actual_run(expected: ExpectedObservationFrame) -> FalsificationRun:
|
||||||
|
missing = tuple(ref.slot_id for ref in expected.unit_refs)
|
||||||
|
residual_payload = {
|
||||||
|
"kind": "FalsificationResidual",
|
||||||
|
"matched": [],
|
||||||
|
"missing": list(missing),
|
||||||
|
"unexpected": [],
|
||||||
|
"changed": [],
|
||||||
|
}
|
||||||
|
residual = FalsificationResidual(
|
||||||
|
matched=(),
|
||||||
|
missing=missing,
|
||||||
|
unexpected=(),
|
||||||
|
changed=(),
|
||||||
|
residual_sha256=sha256_json(residual_payload),
|
||||||
|
)
|
||||||
|
actual_trace_hash = sha256_json({
|
||||||
|
"kind": "MissingActualObservationFrame",
|
||||||
|
"expected_id": expected.expected_id,
|
||||||
|
})
|
||||||
|
trace_payload = {
|
||||||
|
"kind": "FalsificationRun",
|
||||||
|
"expected_id": expected.expected_id,
|
||||||
|
"actual_frame_id": "__missing_observation_frame__",
|
||||||
|
"verdict": "FALSIFIED",
|
||||||
|
"residual_sha256": residual.residual_sha256,
|
||||||
|
"expected_sha256": expected.expected_sha256,
|
||||||
|
"actual_trace_hash": actual_trace_hash,
|
||||||
|
}
|
||||||
|
return FalsificationRun(
|
||||||
|
expected_id=expected.expected_id,
|
||||||
|
actual_frame_id="__missing_observation_frame__",
|
||||||
|
verdict="FALSIFIED",
|
||||||
|
residual=residual,
|
||||||
|
expected_sha256=expected.expected_sha256,
|
||||||
|
actual_trace_hash=actual_trace_hash,
|
||||||
|
trace_hash=sha256_json(trace_payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_falsification_scenario(
|
||||||
|
plan: ExperimentPlan,
|
||||||
|
actual_frames_by_expected_id: Mapping[str, ObservationFrame],
|
||||||
|
actual_refs_by_expected_id: Mapping[str, Iterable[ObservationUnitRef]] | None = None,
|
||||||
|
) -> ScenarioReport:
|
||||||
|
"""Run a plan through the existing expected-vs-actual comparator."""
|
||||||
|
scenario = build_falsification_scenario(
|
||||||
|
plan=plan,
|
||||||
|
actual_frames_by_expected_id=actual_frames_by_expected_id,
|
||||||
|
actual_refs_by_expected_id=actual_refs_by_expected_id,
|
||||||
|
)
|
||||||
|
actual_by_expected = {item.expected_id: item for item in scenario.actual_frames}
|
||||||
|
runs: list[FalsificationRun] = []
|
||||||
|
for expected in scenario.plan.expected_frames:
|
||||||
|
actual_item = actual_by_expected.get(expected.expected_id)
|
||||||
|
if actual_item is None:
|
||||||
|
runs.append(_missing_actual_run(expected))
|
||||||
|
else:
|
||||||
|
runs.append(
|
||||||
|
compare_expected_to_observation(
|
||||||
|
expected,
|
||||||
|
actual_item.frame,
|
||||||
|
actual_refs=actual_item.actual_refs or None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
supported = sum(1 for run in runs if run.verdict == "SUPPORTED")
|
||||||
|
falsified = sum(1 for run in runs if run.verdict == "FALSIFIED")
|
||||||
|
verdict = "FALSIFIED" if falsified else "SUPPORTED"
|
||||||
|
payload = {
|
||||||
|
"kind": "ScenarioReport",
|
||||||
|
"hypothesis_sha256": scenario.plan.hypothesis.hypothesis_sha256,
|
||||||
|
"plan_sha256": scenario.plan.plan_sha256,
|
||||||
|
"scenario_sha256": scenario.scenario_sha256,
|
||||||
|
"verdict": verdict,
|
||||||
|
"total_count": len(runs),
|
||||||
|
"supported_count": supported,
|
||||||
|
"falsified_count": falsified,
|
||||||
|
"run_trace_hashes": [run.trace_hash for run in runs],
|
||||||
|
}
|
||||||
|
return ScenarioReport(
|
||||||
|
hypothesis_sha256=scenario.plan.hypothesis.hypothesis_sha256,
|
||||||
|
plan_sha256=scenario.plan.plan_sha256,
|
||||||
|
scenario_sha256=scenario.scenario_sha256,
|
||||||
|
verdict=verdict,
|
||||||
|
total_count=len(runs),
|
||||||
|
supported_count=supported,
|
||||||
|
falsified_count=falsified,
|
||||||
|
runs=tuple(runs),
|
||||||
|
report_sha256=sha256_json(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExperimentPlan",
|
||||||
|
"FalsificationScenario",
|
||||||
|
"HypothesisClaim",
|
||||||
|
"ScenarioActualFrame",
|
||||||
|
"ScenarioReport",
|
||||||
|
"build_experiment_plan",
|
||||||
|
"build_falsification_scenario",
|
||||||
|
"build_hypothesis_claim",
|
||||||
|
"run_falsification_scenario",
|
||||||
|
]
|
||||||
|
|
@ -9,9 +9,12 @@ import pytest
|
||||||
|
|
||||||
from sensorium.environment import (
|
from sensorium.environment import (
|
||||||
ObservationUnitRef,
|
ObservationUnitRef,
|
||||||
|
build_experiment_plan,
|
||||||
build_expected_observation_frame,
|
build_expected_observation_frame,
|
||||||
|
build_hypothesis_claim,
|
||||||
build_observation_frame,
|
build_observation_frame,
|
||||||
compare_expected_to_observation,
|
compare_expected_to_observation,
|
||||||
|
run_falsification_scenario,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,6 +74,46 @@ def test_expected_frame_hash_is_order_invariant_and_duplicate_safe():
|
||||||
assert len(f1.unit_refs) == 2
|
assert len(f1.unit_refs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_hypothesis_claim_is_speculative_and_basis_order_stable():
|
||||||
|
h1 = build_hypothesis_claim(
|
||||||
|
claim_id="claim-a",
|
||||||
|
claim_text="The anchored object repeats its contact evidence.",
|
||||||
|
domain="passive_tabletop",
|
||||||
|
basis_trace_hashes=("b", "a", "b"),
|
||||||
|
)
|
||||||
|
h2 = build_hypothesis_claim(
|
||||||
|
claim_id="claim-a",
|
||||||
|
claim_text="The anchored object repeats its contact evidence.",
|
||||||
|
domain="passive_tabletop",
|
||||||
|
basis_trace_hashes=("a", "b"),
|
||||||
|
)
|
||||||
|
assert h1.epistemic_status == "SPECULATIVE"
|
||||||
|
assert h1.basis_trace_hashes == ("a", "b")
|
||||||
|
assert h1.hypothesis_sha256 == h2.hypothesis_sha256
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiment_plan_hash_is_order_invariant_and_duplicate_safe():
|
||||||
|
hypothesis = build_hypothesis_claim(
|
||||||
|
claim_id="claim-plan",
|
||||||
|
claim_text="Expected frames define the evidence envelope.",
|
||||||
|
domain="fixture",
|
||||||
|
)
|
||||||
|
first = build_expected_observation_frame(
|
||||||
|
monotonic_tick=10,
|
||||||
|
source_clock="fixture",
|
||||||
|
unit_refs=(_ref("slot:a", "a"),),
|
||||||
|
)
|
||||||
|
second = build_expected_observation_frame(
|
||||||
|
monotonic_tick=11,
|
||||||
|
source_clock="fixture",
|
||||||
|
unit_refs=(_ref("slot:b", "b"),),
|
||||||
|
)
|
||||||
|
p1 = build_experiment_plan(hypothesis=hypothesis, expected_frames=(first, second, first))
|
||||||
|
p2 = build_experiment_plan(hypothesis=hypothesis, expected_frames=(second, first))
|
||||||
|
assert p1.plan_sha256 == p2.plan_sha256
|
||||||
|
assert len(p1.expected_frames) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_raw_payloads_are_rejected():
|
def test_raw_payloads_are_rejected():
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class BadUnit(_Unit):
|
class BadUnit(_Unit):
|
||||||
|
|
@ -147,6 +190,68 @@ def test_missing_unexpected_and_changed_slots_are_falsified():
|
||||||
assert run.residual.changed[0].slot_id == "slot:b"
|
assert run.residual.changed[0].slot_id == "slot:b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_supported_and_falsified_verdicts():
|
||||||
|
hypothesis = build_hypothesis_claim(
|
||||||
|
claim_id="claim-scenario",
|
||||||
|
claim_text="A scenario binds expected evidence to actual frames.",
|
||||||
|
domain="fixture",
|
||||||
|
)
|
||||||
|
refs = (_ref("slot:a", "a"),)
|
||||||
|
expected = build_expected_observation_frame(
|
||||||
|
monotonic_tick=6,
|
||||||
|
source_clock="fixture",
|
||||||
|
unit_refs=refs,
|
||||||
|
)
|
||||||
|
plan = build_experiment_plan(hypothesis=hypothesis, expected_frames=(expected,))
|
||||||
|
supported_actual = build_observation_frame(
|
||||||
|
monotonic_tick=6,
|
||||||
|
source_clock="fixture",
|
||||||
|
units=[ref.unit for ref in refs],
|
||||||
|
)
|
||||||
|
supported = run_falsification_scenario(
|
||||||
|
plan,
|
||||||
|
actual_frames_by_expected_id={expected.expected_id: supported_actual},
|
||||||
|
actual_refs_by_expected_id={expected.expected_id: refs},
|
||||||
|
)
|
||||||
|
assert supported.verdict == "SUPPORTED"
|
||||||
|
assert supported.total_count == 1
|
||||||
|
assert supported.supported_count == 1
|
||||||
|
|
||||||
|
changed_refs = (_ref("slot:a", "changed"),)
|
||||||
|
changed_actual = build_observation_frame(
|
||||||
|
monotonic_tick=6,
|
||||||
|
source_clock="fixture",
|
||||||
|
units=[ref.unit for ref in changed_refs],
|
||||||
|
)
|
||||||
|
falsified = run_falsification_scenario(
|
||||||
|
plan,
|
||||||
|
actual_frames_by_expected_id={expected.expected_id: changed_actual},
|
||||||
|
actual_refs_by_expected_id={expected.expected_id: changed_refs},
|
||||||
|
)
|
||||||
|
assert falsified.verdict == "FALSIFIED"
|
||||||
|
assert falsified.falsified_count == 1
|
||||||
|
assert falsified.runs[0].residual.changed[0].slot_id == "slot:a"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenario_missing_actual_frame_is_falsified():
|
||||||
|
hypothesis = build_hypothesis_claim(
|
||||||
|
claim_id="claim-missing",
|
||||||
|
claim_text="Missing actual evidence falsifies the scenario.",
|
||||||
|
domain="fixture",
|
||||||
|
)
|
||||||
|
expected = build_expected_observation_frame(
|
||||||
|
monotonic_tick=7,
|
||||||
|
source_clock="fixture",
|
||||||
|
unit_refs=(_ref("slot:a", "a"),),
|
||||||
|
)
|
||||||
|
plan = build_experiment_plan(hypothesis=hypothesis, expected_frames=(expected,))
|
||||||
|
report = run_falsification_scenario(plan, actual_frames_by_expected_id={})
|
||||||
|
assert report.verdict == "FALSIFIED"
|
||||||
|
assert report.falsified_count == 1
|
||||||
|
assert report.runs[0].actual_frame_id == "__missing_observation_frame__"
|
||||||
|
assert report.runs[0].residual.missing == ("slot:a",)
|
||||||
|
|
||||||
|
|
||||||
def test_run_trace_is_hash_only():
|
def test_run_trace_is_hash_only():
|
||||||
refs = (_ref("slot:a", "a"),)
|
refs = (_ref("slot:a", "a"),)
|
||||||
expected = build_expected_observation_frame(
|
expected = build_expected_observation_frame(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ def test_environment_falsification_report_passes_expected_fixtures():
|
||||||
assert report["lane"] == "environment-falsification"
|
assert report["lane"] == "environment-falsification"
|
||||||
assert report["failed"] == 0
|
assert report["failed"] == 0
|
||||||
assert report["expected_report_hash_ok"] is True
|
assert report["expected_report_hash_ok"] is True
|
||||||
|
assert report["expected_frame_report_hash_ok"] is True
|
||||||
assert {case["actual_verdict"] for case in report["cases"]} == {"SUPPORTED", "FALSIFIED"}
|
assert {case["actual_verdict"] for case in report["cases"]} == {"SUPPORTED", "FALSIFIED"}
|
||||||
|
assert {case["actual_verdict"] for case in report["scenario_cases"]} == {"SUPPORTED", "FALSIFIED"}
|
||||||
|
|
||||||
|
|
||||||
def test_core_eval_environment_falsification_json(capsys):
|
def test_core_eval_environment_falsification_json(capsys):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue