core/tests/test_inner_loop_phase2.py
Shay 8146844d90 feat(adr-0024): Phases 2-5 — corpus eval, v2 adversarial, threshold characterization, ADR-0025 design note
Phase 2 — Corpus observation runner (inner_loop_runner.py):
- Four-condition matrix: boundary_only / null_control / inner_loop_t0 / inner_loop_tpos.
- Added `inner_loop_force_admit` to generate() — exercises the inner-loop
  code path but force-breaks on first candidate.  Eval-only null control:
  isolates rejection as the causal factor for any pass-rate delta.
- Metrics: pass_rate, mean_rejection_count_per_turn,
  non_empty_rejected_attempts_rate, exhaustion_rate (gated at 5%),
  mean_admissibility_checks_per_turn, mean/p95 added_latency_ms,
  trace_hash_stability across 5 reruns per case.
- Finding on v1+dev: causal_attribution_valid=True, code_path_residual=0.0,
  but exhaustion_rate=0.33 at t=0 — chain outer-product blade is
  geometrically blind to the active pack.
- Tests (tests/test_inner_loop_phase2.py, 5 pass): pin
  causal-attribution and live-corpus trace-hash stability invariants.

Phase 3 — Mechanism-isolation v2 corpus (5 cases, v2_runner.py):
- Synthetic adversarial cases with controlled geometry — each case
  specifies seed_token, admissible_tokens, relation_blade_token, and
  admissibility_threshold.  Field state is constructed directly from
  the seed token versor, not via priming.
- For every case: boundary-only selects the forbidden decoy and
  inner-loop selects the expected endpoint with the forbidden token
  appearing in rejected_attempts.
- Result: mechanism_isolated=true on 5/5.  boundary_decoy_rate=1.0,
  rejection_traced_rate=1.0.  Inner-loop rejection is demonstrably
  doing causal semantic work on real packs.
- Tests (tests/test_inner_loop_phase3.py, 8 pass): GATE on
  mechanism_isolated.

Phase 4 — Threshold characterization (threshold_characterization.py):
- Distribution mapping per-case AND globally on v1+dev, v2, combined.
- Per-threshold sweep over [-1.0, -0.5, 0.0, 0.1, 0.25, 0.5, 1.0].
- Finding: per-case geometry separates cleanly (correct_min > incorrect_max
  on every v2 case), BUT no global static threshold passes the
  separation_quality >= 0.8 gate.  Blade norms vary ~10x across cases.
- Static thresholds (global, relation-typed, or constant frame-derived)
  are geometrically insufficient.  Per-case-normalized thresholds
  (e.g. fraction of blade self-score) are the recommended next step.
- v1 chain-token outer-product cases all skipped — the corpus's chain
  tokens (alpha, beta, gamma, delta) are not grounded in the active
  pack.  Load-bearing finding for ADR-0025 region construction.
- Tests (tests/test_inner_loop_phase4.py, 5 pass): pin the finding
  diagnostically (not gated).

Phase 5 — ADR-0025 design note (draft):
- No code changes proposed.  Scopes three architectural questions:
  (1) home (algebra/versor.py vs field/propagate.py vs generate/) —
      preliminary stance: algebra/versor.py.
  (2) threshold scheme (blade-normalized fraction recommended over
      static; learned/adaptive rejected for determinism).
  (3) teaching-loop boundary — Stance A confirmed: rejections are
      runtime hygiene only, no entanglement with teaching/*.
- Decisions to be closed before Draft → Accepted.

Phase 1 acceptance criteria from previous commit (7fccf36) carry
forward: wired, deterministic-when-wired, legacy hash preserved.

Suite: 1014 passed, 0 failed, 2 skipped.
2026-05-17 14:07:50 -07:00

114 lines
4.1 KiB
Python

"""Phase 2 corpus-observation invariants (ADR-0024 follow-up).
These tests pin the causal-attribution and determinism contracts that
the Phase 2 runner must hold on the existing FSC corpus. They are
intentionally *not* gated on rejection_effect or exhaustion_rate —
those are findings to be characterised in Phase 4, not invariants.
What we *do* assert:
* ``causal_attribution_valid`` is True: the null control (inner-loop
code path on, force-admit on) matches boundary-only exactly. Any
pass-rate delta between inner_loop_t0 and boundary_only is then
attributable to rejection, not to incidental code-path effects.
* ``code_path_residual`` is zero (within float tolerance).
* Trace-hash stability holds for the inner-loop condition on every
non-skipped case (5 reruns produce identical hashes).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from evals.forward_semantic_control.inner_loop_runner import run_lane
_CORPUS_PATHS = (
Path("evals/forward_semantic_control/public/v1/cases.jsonl"),
Path("evals/forward_semantic_control/dev/cases.jsonl"),
)
def _load_corpus() -> list[dict]:
cases: list[dict] = []
for path in _CORPUS_PATHS:
if not path.exists():
continue
with path.open() as fh:
cases.extend(json.loads(line) for line in fh if line.strip())
return cases
@pytest.fixture(scope="module")
def phase2_report():
cases = _load_corpus()
if not cases:
pytest.skip("FSC corpus not available")
return run_lane(cases)
class TestCausalAttribution:
def test_null_control_matches_boundary_only(self, phase2_report) -> None:
"""Null control must reproduce boundary-only pass-rate exactly.
If this fails, the inner-loop code path is itself altering
selection (call ordering, telemetry side effects), and any
rejection_effect we measure is contaminated. ADR-0024 proof
depends on this invariant.
"""
assert phase2_report.metrics["causal_attribution_valid"] is True
assert phase2_report.metrics["code_path_residual"] == 0.0
def test_null_control_per_condition_metrics(self, phase2_report) -> None:
per = phase2_report.metrics["per_condition"]
assert per["null_control"]["pass_rate"] == per["boundary_only"]["pass_rate"]
# Null control must produce zero rejections by construction.
assert per["null_control"]["mean_rejection_count_per_turn"] == 0
assert per["null_control"]["non_empty_rejected_attempts_rate"] == 0.0
assert per["null_control"]["exhaustion_rate"] == 0.0
class TestInnerLoopDeterminismOnCorpus:
def test_inner_loop_t0_hash_stable_on_every_case(self, phase2_report) -> None:
"""Live-corpus version of the Phase 1 acceptance test.
Stub-vocab determinism is necessary but not sufficient — the
same property must hold on actual packs, actual field state,
actual rejection sequences. 5 reruns per case must hash
identically.
"""
rate = phase2_report.metrics["per_condition"]["inner_loop_t0"][
"trace_hash_stability_pass_rate"
]
assert rate == 1.0
class TestPhase2RecordsFindings:
"""These are not gates — they record the Phase 2 finding so a
future change that silently flips the sign of rejection_effect or
closes the exhaustion gap is visible in test output."""
def test_runner_emits_required_metric_keys(self, phase2_report) -> None:
required = {
"per_condition",
"rejection_effect",
"code_path_residual",
"causal_attribution_valid",
"exhaustion_ceiling",
"exhaustion_gate_pass",
"probe_threshold_positive",
"case_count",
"skipped_count",
}
assert required <= set(phase2_report.metrics.keys())
def test_all_four_conditions_present(self, phase2_report) -> None:
per = phase2_report.metrics["per_condition"]
assert set(per.keys()) == {
"boundary_only",
"null_control",
"inner_loop_t0",
"inner_loop_tpos",
}