# ADR-0225: ContractResidual Read-Model

**Status:** Accepted
**Author:** Antigravity
**Date:** 2026-06-22

## Ratification note

Accepted for implementation after #860 review.

This ADR authorizes exactly one diagnostic-only implementation PR for the `ContractResidual` projection over existing `ContractAssessment` outputs.

The authorization does not include SearchGate, ComputeBudgetPolicy, GeometricSearchRun, Workbench implementation, serving behavior, teaching/proposal/report/eval mutation, or runtime repair. Those remain separately gated by future ADRs/PRs.

## Context

CORE has introduced a proposal-first diagnostic construction pipeline for representing mathematical word problems and relations within the semantic substrate. In this architecture:
* Surface and process evidence is scanned to produce hypothesis-only `ConstructionProposal` records with `status="proposed"`.
* Mentions, bindings, and local roles are bound to ground these proposals.
* `ContractAssessment` serves as the sole runnable/refused authority, verifying the algebraic and semantic validity of the candidate structures.
* PR #859 aligned the `state_change.unary_delta` (candidate organ `"unary_delta_transition"`) implementation with its accepted spec.

As the pipeline scales, downstream planning needs a deterministic, structured mechanism to understand *why* a particular contract was refused (i.e., why a diagnostic organ did not close), without introducing unreviewed search behavior, serving authority, or mutative repair loops.

## Problem

Currently, `ContractAssessment` records capture diagnostic failure information using:
* `candidate_organ`
* `missing_bindings`
* `unresolved_hazards`
* `runnable` (boolean status)
* `explanation` (prose description)
* `evidence_spans`

While this structure is sufficient for simple validation, downstream planning and operator reasoning require a normalized, structured **read-model** to classify failures into fine-grained, structured categories, such as:
* Missing required role bindings
* Ambiguity in roles or relations
* Inexact provenance spans
* Unsupported topologies
* Blocking hazards

This read-model must classify the diagnostics deterministically, without changing the underlying contract definitions, making search decisions, or granting compute budget.

## Decision

We propose the introduction of a scoped, read-only `ContractResidual` read-model.

### Normative Authority Rule
> [!IMPORTANT]
> `ContractAssessment` remains the sole runnable/refused authority.
> `ContractResidual` is a pure read-only projection over `ContractAssessment` records. It **never** authorizes serving, search, mutation, learning, answer production, or repair of the `ProblemFrame`.

### Conceptual Shape
The conceptual schema for `ContractResidual` is defined as follows:

```python
@dataclass(frozen=True, slots=True)
class ContractResidual:
    residual_id: str
    candidate_organ: str
    family_id: str | None
    residual_kind: ResidualKind
    residual_code: str
    source_axis: ResidualSourceAxis
    evidence_spans: tuple[SourceSpan, ...]
    explanation: str
```
*(Note: This is documentation only. The Python classes are not implemented in this PR.)*

## ResidualKind Taxonomy

We define a closed taxonomy of `ResidualKind` to classify existing `ContractAssessment` labels:

1. **MISSING_PROPOSAL**: The required construction proposal is absent.
2. **MISSING_RELATION**: The underlying relation edge (e.g., `quantity_entity`) is missing.
3. **MISSING_ROLE**: A required role slot on the relation is unbound.
4. **AMBIGUOUS_RELATION**: Multiple candidate relations exist with no unique resolution.
5. **AMBIGUOUS_ROLE**: A role binds to multiple candidate mentions ambiguously.
6. **INEXACT_PROVENANCE**: Spans or quantities lack exact, source-grounded provenance.
7. **NONLOCAL_BINDING**: The binding requires non-local references (e.g., pronoun resolution or cross-sentence leaps).
8. **UNSUPPORTED_TOPOLOGY**: The clause contains grammar or structure unsupported by the family (e.g., passive voice, multiple coordinate actors).
9. **UNIT_OBJECT_CONFLICT**: Grounded quantity units conflict with the object type (e.g., count vs. measurement mismatch).
10. **HAZARD_BLOCKED**: An active semantic confuser or hazard blocks the contract.
11. **TARGET_UNBOUND**: The question target cannot be bound or is missing.
12. **CONTRACT_GAP_UNCLASSIFIED**: A fallback classification for unmapped contract failures (non-searchable).

### Rules
* The taxonomy classifies existing `ContractAssessment` labels; it does not replace them.
* `residual_code` must preserve the original missing binding or unresolved hazard label exactly.
* `CONTRACT_GAP_UNCLASSIFIED` is restricted to non-searchable diagnostic fallback only.
* Broad, vague, or non-deterministic kinds (e.g., `MATH_FAILED`, `REASONING_FAILED`, `UNKNOWN_TRUTH`) are architecturally forbidden and structurally disallowed.

## ResidualSourceAxis

To decouple the *kind* of failure from the *structural layer* that produced it, we define `ResidualSourceAxis`:

1. **PROPOSAL**: Bounded proposal detection layer.
2. **RELATION**: Binding graph/relational edge layer.
3. **ROLE**: Slot-binding role layer.
4. **PROVENANCE**: Source-grounding and span layer.
5. **LOCALITY**: Clause and sentence distance bounds.
6. **TOPOLOGY**: Grammatical and structural patterns.
7. **HAZARD**: Cognitive/confuser hazard detection.
8. **TARGET**: Question target binding.
9. **UNIT_OBJECT**: Count/measurement unit boundary.
10. **UNKNOWN**: Fallback for unmapped boundaries.

## Mapping from Existing Labels

The mapping from existing contract labels (missing bindings and unresolved hazards) to `ResidualKind` and `ResidualSourceAxis` is deterministic. The following table maps the labels from current contract families:

| Existing Contract Label | ResidualKind | ResidualSourceAxis | Notes / Rationale |
|---|---|---|---|
| **Quantity Entity (`binding.quantity_entity`)** | | | |
| `quantity_entity_proposal_required` | `MISSING_PROPOSAL` | `PROPOSAL` | Missing the core proposal |
| `quantity_unbound` | `MISSING_ROLE` | `ROLE` | Missing the quantity role |
| `entity_unbound` | `MISSING_ROLE` | `ROLE` | Missing the entity role |
| `quantity_ambiguous` | `AMBIGUOUS_ROLE` | `ROLE` | Multiple candidate quantities |
| `entity_ambiguous` | `AMBIGUOUS_ROLE` | `ROLE` | Multiple candidate entities |
| `local_binding_relation_unbound` | `MISSING_RELATION` | `RELATION` | No linking relation |
| `local_binding_relation_ambiguous` | `AMBIGUOUS_RELATION` | `RELATION` | Multiple local bindings |
| `quantity_kind_unresolved` | `MISSING_ROLE` | `ROLE` | Missing type disposition |
| `unit_kind_conflict` | `UNIT_OBJECT_CONFLICT` | `UNIT_OBJECT` | Count vs. measure conflict |
| `provenance_span_inexact` | `INEXACT_PROVENANCE` | `PROVENANCE` | Spans do not match exactly |
| `quantity_entity_nonlocal` | `NONLOCAL_BINDING` | `LOCALITY` | Binding spans are non-local |
| `competing_family_context` | `HAZARD_BLOCKED` | `HAZARD` | Hazard from competing triggers |
| `percent_change_vs_percent_of` | `HAZARD_BLOCKED` | `HAZARD` | Percentage ambiguity hazard |
| **Fraction Decrease (`proportional_change.decrease_to_fraction`)** | | | |
| `decrease_relation_ambiguous` | `AMBIGUOUS_RELATION` | `RELATION` | Ambiguous relation path |
| `base_quantity_unbound` | `MISSING_ROLE` | `ROLE` | Missing base quantity role |
| `scale_unbound` | `MISSING_ROLE` | `ROLE` | Missing scale fraction role |
| `state_entity_unbound` | `MISSING_ROLE` | `ROLE` | Missing state entity role |
| `base_quantity_provenance_missing` | `INEXACT_PROVENANCE` | `PROVENANCE` | Base quantity lacks provenance |
| `scale_provenance_missing` | `INEXACT_PROVENANCE` | `PROVENANCE` | Scale lacks provenance |
| `unit_continuity_unproven` | `UNIT_OBJECT_CONFLICT` | `UNIT_OBJECT` | Mismatched units along continuity axis |
| `delta_decrease_target_unbound` | `TARGET_UNBOUND` | `TARGET` | Unbound decrease target |
| `delta_decrease_target_required` | `TARGET_UNBOUND` | `TARGET` | Required decrease target is missing |
| `state_entity_continuity_unproven` | `HAZARD_BLOCKED` | `HAZARD` | Ambiguity in entity tracking |
| `scale_out_of_range` | `HAZARD_BLOCKED` | `HAZARD` | Scale value outside (0, 1) bounds |
| **Percent Partition (`partition.percent_partition`)** | | | |
| `grounded_partition_subgroup` | `MISSING_ROLE` | `ROLE` | Missing partition part roles |
| `grounded_whole_entity` | `MISSING_ROLE` | `ROLE` | Missing whole role entity |
| `original_whole_unbound` | `MISSING_ROLE` | `ROLE` | Whole is unbound |
| `multiple_original_whole_candidates` | `AMBIGUOUS_ROLE` | `ROLE` | Ambiguity between whole candidates |
| `partition_subgroups_not_distinct` | `HAZARD_BLOCKED` | `HAZARD` | Subgroups share entities/mentions |
| `percent_subgroup_links_incomplete` | `UNSUPPORTED_TOPOLOGY` | `TOPOLOGY` | Incomplete partition topology |
| `grounded_question_target` | `TARGET_UNBOUND` | `TARGET` | Unbound question target |
| `forward_aggregate_target_required` | `TARGET_UNBOUND` | `TARGET` | Aggregate target required for partition |
| `inverse_topology_unlicensed` | `UNSUPPORTED_TOPOLOGY` | `TOPOLOGY` | Inverse query topology unsupported |
| **Unary Delta (`state_change.unary_delta`)** | | | |
| `unary_delta_proposal_required` | `MISSING_PROPOSAL` | `PROPOSAL` | Missing the core proposal |
| `unary_delta_relation_ambiguous` | `AMBIGUOUS_RELATION` | `RELATION` | Ambiguous relation path |
| `action_cue_unbound` | `MISSING_ROLE` | `ROLE` | Missing action cue role |
| `delta_quantity_unbound` | `MISSING_ROLE` | `ROLE` | Missing quantity role |
| `delta_quantity_ambiguous` | `AMBIGUOUS_ROLE` | `ROLE` | Multiple candidate quantities |
| `changed_object_unbound` | `MISSING_ROLE` | `ROLE` | Missing changed object role |
| `changed_object_ambiguous` | `AMBIGUOUS_ROLE` | `ROLE` | Multiple candidate objects |
| `local_binding_relation_unbound` | `MISSING_RELATION` | `RELATION` | No linking relation |
| `direction_unbound` | `MISSING_ROLE` | `ROLE` | Missing direction role |
| `quantity_kind_unresolved` | `MISSING_ROLE` | `ROLE` | Missing type disposition |
| `unit_object_conflict` | `UNIT_OBJECT_CONFLICT` | `UNIT_OBJECT` | Count vs. measure conflict |
| `provenance_span_inexact` | `INEXACT_PROVENANCE` | `PROVENANCE` | Spans do not match exactly |
| `quantity_entity_nonlocal` | `NONLOCAL_BINDING` | `LOCALITY` | Spans are non-local |
| `pronoun_antecedent_unresolved` | `NONLOCAL_BINDING` | `LOCALITY` | Pronoun requires reference resolution |
| `event_assertion_unlicensed` | `UNSUPPORTED_TOPOLOGY` | `TOPOLOGY` | Unlicensed event assertion topology |
| `passive_voice_unsupported` | `UNSUPPORTED_TOPOLOGY` | `TOPOLOGY` | Passive voice topology is unsupported |
| `multiple_actor_surface` | `UNSUPPORTED_TOPOLOGY` | `TOPOLOGY` | Multiple coordinate actors unsupported |

*(Note: No new runtime labels are created in this PR. If a label is unclear or needs modification, it must go through a formal preflight review before implementation.)*

## Non-Authority Guarantees

To prevent scope creep and preserve the deterministic nature of the cognitive engine:
* **No proposals:** Residuals describe why a candidate contract failed to close; they are never proposals and cannot be registered in the construction catalog.
* **No proofs:** Residuals carry no proof value and do not establish the validity of any mathematical assertion.
* **No search commands:** Residuals do not instruct the engine to expand the candidate graph, change search paths, or adjust retry strategies.
* **No budget grants:** Residuals do not allocate compute budget, trigger model-based retries, or define practice lanes.
* **No teaching proposals:** Residuals are not mutation proposals for training, packs, or policies.
* **No Workbench actions:** Residuals do not mutate the database or trigger execution. They are strictly read-only diagnostics for visual inspection.
* **No serving decisions:** Serving paths remain entirely frozen. Residuals cannot flip a contract to `runnable`, repair a `ProblemFrame`, or generate answers.

## Relationship to SearchGate

The boundary between `ContractResidual` and the future SearchGate is strictly delineated:
* **Future Input Only:** SearchGate may later consume `ContractResidual` records as input to make search decisions, but SearchGate is a separate future ADR/PR.
* **Forbidden on `ContractResidual`:**
  * No `search_eligible` boolean on `ContractResidual`.
  * No compute budget configuration.
  * No retry policy or loop rules.
  * No search run schema.
  * No candidate expansion or node generation algorithms.
  * No sealed or practice lane integration.

## Relationship to Workbench

The boundary between `ContractResidual` and Workbench is defined as:
* **Read-Only Display Only:** Workbench may later display `ContractResidual` records as read-only operator diagnostics.
* **Forbidden in Workbench:**
  * No UI implementation of fix actions (e.g., "fix this" buttons).
  * No mutation controls.
  * No editing of proposals from the residual interface.
  * No human feedback or interactive correction flow linked to residuals.

## Determinism and Replay

`ContractResidual` projections must be fully deterministic and replay-stable:
* **Stable IDs:** The `residual_id` must be constructed using a stable hashing mechanism from its constituent fields:
  ```text
  residual_id = stable_hash(candidate_organ, residual_kind, residual_code, ordered_evidence_spans)
  ```
  *(Note: Hashing code is not implemented in this PR.)*
* **Preserved Spans:** Source spans must be preserved exactly without widening, concatenation, or synthesis.
* **Deterministic Ordering:** Residuals must be ordered deterministically based on their `residual_id`.
* **No Side Effects:** Residual generation must not contain timestamps, random IDs, environment-dependent data, or model-generated text.

## Examples

### Example A: Missing Changed Object / Missing Relation
**Input:** `"Tom gained 3."`
* **Behavior:** A unary-delta proposal exists based on the exact cue `"gained"`.
* **Contract Assessment:** `ContractAssessment(candidate_organ="unary_delta_transition")` refuses due to a missing changed object and local relation.
* **Residual Projection:**
  ```python
  ContractResidual(
      residual_id="stable_hash_a",
      candidate_organ="unary_delta_transition",
      family_id="state_change.unary_delta",
      residual_kind=ResidualKind.MISSING_ROLE,
      residual_code="changed_object_unbound",
      source_axis=ResidualSourceAxis.ROLE,
      evidence_spans=(SourceSpan(text="gained", start=4, end=10),),
      explanation="The changed object role is unbound.",
  )
  ```
* **Status:** No answer/search/serving is triggered.

### Example B: Missing Delta Quantity
**Input:** `"Tom gained apples."`
* **Behavior:** The cue `"gained"` proposes. The quantity is missing.
* **Contract Assessment:** Refuses due to missing `delta_quantity`.
* **Residual Projection:**
  ```python
  ContractResidual(
      residual_id="stable_hash_b",
      candidate_organ="unary_delta_transition",
      family_id="state_change.unary_delta",
      residual_kind=ResidualKind.MISSING_ROLE,
      residual_code="delta_quantity_unbound",
      source_axis=ResidualSourceAxis.ROLE,
      evidence_spans=(SourceSpan(text="gained", start=4, end=10),),
      explanation="The delta quantity role is unbound.",
  )
  ```
* **Status:** No synthetic quantity is introduced; remains refused.

### Example C: Unsupported Passive Topology
**Input:** `"3 apples were gained by Tom."`
* **Behavior:** The passive voice cue structures are unsupported by the closed recognizer.
* **Contract Assessment:** Refuses due to passive topology / event assertion unlicensed.
* **Residual Projection:**
  ```python
  ContractResidual(
      residual_id="stable_hash_c",
      candidate_organ="unary_delta_transition",
      family_id="state_change.unary_delta",
      residual_kind=ResidualKind.UNSUPPORTED_TOPOLOGY,
      residual_code="passive_voice_unsupported",
      source_axis=ResidualSourceAxis.TOPOLOGY,
      evidence_spans=(SourceSpan(text="gained", start=14, end=20),),
      explanation="Passive voice topology is unsupported.",
  )
  ```
* **Status:** No role repair is attempted.

### Example D: Unit-Object Conflict
**Input:** `"Tom gained 3 degrees."`
* **Behavior:** Quantity unit `"degrees"` conflicts with object count/entity expectations.
* **Contract Assessment:** Refuses due to `unit_object_conflict`.
* **Residual Projection:**
  ```python
  ContractResidual(
      residual_id="stable_hash_d",
      candidate_organ="unary_delta_transition",
      family_id="state_change.unary_delta",
      residual_kind=ResidualKind.UNIT_OBJECT_CONFLICT,
      residual_code="unit_object_conflict",
      source_axis=ResidualSourceAxis.UNIT_OBJECT,
      evidence_spans=(SourceSpan(text="degrees", start=13, end=20),),
      explanation="Quantity unit conflicts with object type.",
  )
  ```
* **Status:** No arithmetic or state update is performed.

## Non-Goals

This ADR explicitly does not cover:
* Implementation of `ContractResidual` or Python dataclasses.
* Integration with SearchGate, ComputeBudgetPolicy, or GeometricSearchRun.
* Mutating or displaying in Workbench.
* Serving path alterations or runtime dispatch.
* Adding new contract labels or modifying the existing assessment logic.
* Adding tests, reports, evals, teaching loops, or semantic pack modifications.
* Performing arithmetic, answer derivation, or solving.

## Acceptance Criteria

This ADR is successfully ratified only if:
1. It is documentation-only.
2. It defines `ContractResidual` as a strict read-only projection.
3. It preserves `ContractAssessment` as the sole runnable authority.
4. It defines a small closed taxonomy for `ResidualKind` and `ResidualSourceAxis`.
5. It deterministically maps existing labels without inventing runtime labels.
6. It freezes SearchGate/ComputeBudget/Workbench implementation boundaries.
7. `git diff --check` passes cleanly.
