core/tests/test_capability_index.py
Shay 1ff06726a6
feat(determine): add transitive strict-order relational inference (#781)
* feat(determine): add transitive strict-order relational inference

Add sound transitive closure for declared strict-order relational predicates —
less_than, greater_than, before_event, after_event, and ONLY these. A query
p(a, c) determines True when a same-predicate chain p(a, b), p(b, c), ... over the
predicate's OWN realized edges is found (BFS reachability) and the proof_chain ROBDD
verifies the transitive entailment (search-then-verify, through the UNCHANGED
evaluate_entailment via a new lower_transitive_chain). Mirrors _determine_subsumption.

Scope / firewall:
- TRANSITIVE_PREDICATES is closed and default-off; sibling_of, parent_of,
  left_of/right_of, inside_of, during_event, overlaps_event are EXCLUDED.
- Same-predicate edges only — no transitive-through-inverse, no cross-predicate
  (triple firewall: recall filter + lowering re-reject + ROBDD re-verify).
- Open-world: asserts only answer=True via the shared _relational_determined
  surface (rule="transitive"), so INV-30 stays at exactly 3 Determined sites and
  no answer=False is added. One-hop inverse/symmetric (#775) is unchanged.
  FrameVerdict / closed-world (ADR-0222) is untouched.

wrong=0 bite (unit + lane + independent oracle): non-transitive-predicate chains,
non-admitted spatial chains, mixed-predicate chains, disjoint chains, reflexive
cycles, and inverse+transitive composition all REFUSE.

Measurement: new evals/relational_transitive lane with an INDEPENDENT BFS
transitive-closure oracle (imports no engine module; INV-25/27), cross-checked both
directions (positives oracle-True, confusers oracle-False). Capability-index breadth
10 -> 11, wrong_total 0, deterministic digest re-frozen.

Cross-PR reconcile: migrated the one-hop confuser rinf-ref-001 (a less_than chain,
which now correctly determines transitively) to a B2 positive; repurposed its unit
test to a non-transitive parent_of chain; corrected a pre-existing stale breadth==9
assertion (#775 added a 10th domain but never updated test_capability_index).

Verified in the worktree: determine + transitive/one-hop lanes + capability
baseline/index + ProofWriter-OWA floor + INV-30/29/21/02 + full smoke — all green.
A 3-skeptic read-only adversarial review found no wrong=0 leak.

* test(determine): B2 hardening — TRANSITIVE_PREDICATES pin + lower_transitive defensive + budget

Add the required B2 hardening tests before merge:

- test_transitive_predicates_closed_and_excludes: the table is EXACTLY
  {less_than, greater_than, before_event, after_event}, every member is in
  RELATIONAL_PREDICATES, and every deliberately-excluded predicate (sibling_of,
  spouse_of, parent_of, child_of, left_of, right_of, inside_of, during_event,
  overlaps_event) is explicitly absent. (Closes the gap where relational.py's
  comment claimed this test existed but it did not.)

- tests/test_composition_lower_transitive.py: direct defensive tests on
  lower_transitive_chain — empty path, mislabeled (cross-predicate) edge, wrong
  arity, non-contiguous path, and path-not-reaching-target all lower to None
  (refuse); plus exact 2-hop and 3-hop theory pins.

- test_edge_budget_exhaustion_refuses: above _TRANSITIVE_EDGE_BUDGET the transitive
  search declines (a safe coverage refusal), never proves.
2026-06-15 14:20:30 -07:00

126 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cross-domain capability index — AGI-roadmap Phase 1 (MEASURE).
The yardstick that gates every later "more capable" claim. Two honest axes —
**accuracy** (of committed answers; wrong stays 0 in assert mode) and **coverage**
(attempted-not-refused) — aggregated across domains so it CANNOT be gamed by a
narrow per-domain win: the headline coverage is the GEOMETRIC MEAN across domains,
which only rises if *every* domain rises. A hack that maxes one lane and leaves
the rest at zero leaves the geomean ~0.
"""
from __future__ import annotations
from evals.capability_index.index import (
DomainResult,
aggregate,
deterministic_digest,
)
def _d(domain: str, correct: int, wrong: int, refused: int) -> DomainResult:
return DomainResult(domain=domain, correct=correct, wrong=wrong, refused=refused)
def test_domain_result_axes() -> None:
r = _d("logic", correct=8, wrong=0, refused=2)
assert r.total == 10
assert r.attempted == 8
assert r.coverage == 0.8
assert r.accuracy == 1.0 # of committed answers
def test_aggregate_axes_micro() -> None:
idx = aggregate([_d("a", 6, 0, 4), _d("b", 2, 0, 8)])
assert idx.wrong_total == 0
assert idx.coverage == 0.4 # (6+2)/(10+10) micro
assert idx.accuracy == 1.0 # no wrong
assert idx.breadth == 2 # both domains have some coverage
def test_geomean_coverage_resists_narrow_gaming() -> None:
# A NARROW hack: one domain maxed, the rest at zero coverage.
narrow = aggregate(
[_d("gamed", 10, 0, 0), _d("x", 0, 0, 10), _d("y", 0, 0, 10)]
)
# A BALANCED engine: every domain partially covered.
balanced = aggregate(
[_d("gamed", 4, 0, 6), _d("x", 4, 0, 6), _d("y", 4, 0, 6)]
)
# Micro-coverage is similar (~0.33 vs 0.40), but the geomean exposes the hack:
assert narrow.coverage_geomean == 0.0 # any zero-coverage domain -> geomean 0
assert balanced.coverage_geomean > 0.39
# The capability score (geomean × accuracy) refuses to reward the narrow hack.
assert narrow.capability_score == 0.0
assert balanced.capability_score > 0.39
def test_balanced_progress_moves_the_score_monotonically() -> None:
low = aggregate([_d("a", 2, 0, 8), _d("b", 2, 0, 8)])
high = aggregate([_d("a", 6, 0, 4), _d("b", 6, 0, 4)])
assert high.coverage_geomean > low.coverage_geomean
assert high.capability_score > low.capability_score
def test_wrong_is_a_hard_gate() -> None:
# In assert mode wrong MUST be 0; any wrong invalidates the index (score 0)
# and is surfaced — never averaged away.
idx = aggregate([_d("a", 8, 1, 1), _d("b", 5, 0, 5)])
assert idx.wrong_total == 1
assert idx.assert_mode_valid is False
assert idx.capability_score == 0.0 # wrong=0 is non-negotiable in assert mode
def test_digest_is_deterministic_and_bites() -> None:
a = aggregate([_d("a", 6, 0, 4), _d("b", 2, 0, 8)])
b = aggregate([_d("a", 6, 0, 4), _d("b", 2, 0, 8)])
assert deterministic_digest(a) == deterministic_digest(b)
moved = aggregate([_d("a", 7, 0, 3), _d("b", 2, 0, 8)])
assert deterministic_digest(moved) != deterministic_digest(a)
def test_empty_index_is_well_defined() -> None:
idx = aggregate([])
assert idx.coverage == 0.0
assert idx.coverage_geomean == 0.0
assert idx.breadth == 0
assert idx.capability_score == 0.0
def test_real_lanes_compose_into_the_index_with_wrong_zero() -> None:
# The baseline: three structured-input reasoning lanes PLUS the comprehension lanes
# (prose -> MeaningGraph -> projection -> independent oracle / pack-named predicate)
# compose into the cross-domain index with zero wrong commits. The one-hop
# relational_inference lane (#775) is the 10th domain; the transitive
# relational_transitive lane (B2) is the 11th. (This assertion was stale at 9 from
# the #596 era — #775's 10th domain was never added here; corrected with B2.)
from evals.capability_index.adapters import collect_domain_results
collection = collect_domain_results()
assert collection.not_covered == () # every adapter ran (no silent drop)
idx = aggregate(list(collection.results))
assert idx.wrong_total == 0
assert idx.assert_mode_valid
assert idx.breadth == 11
assert {d.domain for d in idx.domains} == {
"deductive_logic",
"dimensional",
"relational_metric",
"comprehension_set_membership",
"comprehension_syllogism",
"comprehension_total_ordering",
"comprehension_propositional",
"comprehension_relational_metric",
"comprehension_relational_predicate",
"comprehension_relational_inference",
"comprehension_relational_transitive",
}
assert idx.capability_score > 0.5 # real, non-trivial cross-domain capability
def test_index_report_is_deterministic_across_runs() -> None:
# The capability number is reproducible — improvement is a replayable curve.
from evals.capability_index.adapters import collect_domain_results
a = deterministic_digest(aggregate(list(collect_domain_results().results)))
b = deterministic_digest(aggregate(list(collect_domain_results().results)))
assert a == b